0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ルービックキューブを次元削減して遊ぶ

0
Last updated at Posted at 2026-10-18

はじめに

ルービックキューブ(3×3×3)は通常、3次元空間の立体として解釈・操作されます。一般的な平面表現といえば十字型の展開図ですが、展開図上では 「面を回したときにピースがどの軌道を通って隣の面に移るのか」という連続的な回転運動 が途切れてしまい、直感的に追うのが難しくなります。

そこで今回、「3次元の回転対称性を2次元平面上の同心円ネットワークに射影(次元削減)し、3Dと2Dがリアルタイムに完全連動するシミュレーター」 をHTML/CSS/JavaScript(外部ライブラリなし、単一ファイル)で実装してみました。

【関連動画】

【埋め込み済みコード】

サンプル画像

image.png

ソースコード

コードを展開する (index.html)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>3D & 2D Animated Cube Simulator</title>
<style>
  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
  }

  body {
    background-color: #030712;
    color: #f8fafc;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans JP", sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 100vh;
    padding: 6px 4px;
    overflow-x: hidden;
  }

  .bg-grid {
    position: fixed;
    inset: 0;
    background-image: radial-gradient(rgba(56, 189, 248, 0.12) 1px, transparent 1px);
    background-size: 20px 20px;
    pointer-events: none;
    z-index: 0;
  }

  header {
    text-align: center;
    margin-bottom: 4px;
    z-index: 10;
  }
  header h1 {
    font-size: 15px;
    font-weight: 900;
    color: #38bdf8;
    letter-spacing: 0.05em;
  }

  /* 表示モード切り替えスイッチ */
  .view-switch {
    display: flex;
    background: #091124;
    border-radius: 20px;
    padding: 2px;
    margin-bottom: 4px;
    gap: 2px;
    border: 1px solid rgba(56, 189, 248, 0.3);
    z-index: 10;
  }
  .view-btn {
    background: transparent;
    border: none;
    color: #94a3b8;
    font-size: 10px;
    font-weight: 800;
    padding: 4px 12px;
    border-radius: 16px;
    cursor: pointer;
  }
  .view-btn.active {
    background: #0284c7;
    color: #ffffff;
  }

  /* ビューアエリア (3D & 2D) */
  .viewer-container {
    width: 100%;
    max-width: 360px;
    height: 205px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 4px;
    z-index: 10;
    margin-bottom: 4px;
  }

  /* 3D キューブ側 */
  .scene-container {
    flex: 1;
    height: 100%;
    perspective: 700px;
    display: flex;
    align-items: center;
    justify-content: center;
    touch-action: none;
    position: relative;
    background: rgba(15, 23, 42, 0.4);
    border-radius: 12px;
    border: 1px solid rgba(255, 255, 255, 0.05);
    overflow: hidden;
  }

  .cube-world {
    position: relative;
    width: 96px;
    height: 96px;
    transform-style: preserve-3d;
  }

  .turn-group {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
  }

  /* ピースサイズ: 32px (3x3で96px) */
  .piece {
    position: absolute;
    width: 32px;
    height: 32px;
    transform-style: preserve-3d;
  }

  .sticker {
    position: absolute;
    width: 30px;
    height: 30px;
    box-sizing: border-box;
    border: 1.5px solid #07101a;
    border-radius: 5px;
    box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.4);
    backface-visibility: hidden;
  }

  /* 2D ネットワーク側 */
  .network-container {
    flex: 1;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: rgba(15, 23, 42, 0.4);
    border-radius: 12px;
    border: 1px solid rgba(255, 255, 255, 0.05);
    position: relative;
    overflow: hidden;
  }

  .view-tag {
    position: absolute;
    top: 4px;
    left: 6px;
    font-size: 9px;
    font-weight: 900;
    color: #38bdf8;
    letter-spacing: 0.05em;
    font-family: monospace;
    pointer-events: none;
    z-index: 5;
  }

  /* 操作パネル */
  .controls-panel {
    width: 100%;
    max-width: 360px;
    background: rgba(15, 23, 42, 0.92);
    border: 1px solid rgba(56, 189, 248, 0.25);
    border-radius: 12px;
    padding: 8px;
    backdrop-filter: blur(8px);
    z-index: 10;
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  .tabs {
    display: flex;
    background: #091124;
    border-radius: 6px;
    padding: 2px;
    gap: 2px;
  }
  .tab-btn {
    flex: 1;
    background: transparent;
    border: none;
    color: #94a3b8;
    font-size: 10px;
    font-weight: 800;
    padding: 5px 0;
    border-radius: 5px;
    cursor: pointer;
  }
  .tab-btn.active {
    background: #0284c7;
    color: #fff;
  }

  .btn-grid {
    display: grid;
    grid-template-columns: repeat(6, 1fr);
    gap: 4px;
  }

  button.cube-btn {
    background: #1e293b;
    color: #f8fafc;
    border: 1px solid #334155;
    padding: 8px 0;
    border-radius: 6px;
    font-family: monospace;
    font-weight: 900;
    font-size: 14px;
    cursor: pointer;
  }
  button.cube-btn:active {
    background: #0284c7;
    border-color: #38bdf8;
  }

  .util-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 6px;
  }
  .btn-util {
    background: #0d233a;
    color: #38bdf8;
    border: 1px solid #0284c7;
    font-size: 11px;
    font-weight: 800;
    padding: 6px 0;
    border-radius: 6px;
  }

  .status-bar {
    font-size: 10px;
    font-family: monospace;
    color: #94a3b8;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
</style>
</head>
<body>
<div class="bg-grid"></div>

<header>
  <h1>3D & 2D DUAL CUBE</h1>
</header>

<div class="view-switch">
  <button class="view-btn active" id="btnDual" onclick="setViewMode('dual')">並列 (3D + 2D)</button>
  <button class="view-btn" id="btn3D" onclick="setViewMode('3d')">3Dのみ</button>
  <button class="view-btn" id="btn2D" onclick="setViewMode('2d')">2Dのみ</button>
</div>

<div class="viewer-container" id="viewerContainer">
  <!-- 3D キューブ -->
  <div class="scene-container" id="sceneContainer">
    <div class="view-tag">3D SPACE</div>
    <div class="cube-world" id="cubeWorld"></div>
  </div>

  <!-- 2D ネットワーク -->
  <div class="network-container" id="networkContainer">
    <div class="view-tag">2D PROJECTION</div>
    <svg id="svg2D" viewBox="80 -15 420 420" style="width: 100%; height: 100%; overflow: visible;"></svg>
  </div>
</div>

<div class="controls-panel">
  <div class="tabs">
    <button class="tab-btn active" id="tabCW" onclick="switchTab('cw')">時計回り</button>
    <button class="tab-btn" id="tabCCW" onclick="switchTab('ccw')">逆回転 (')</button>
    <button class="tab-btn" id="tabSlice" onclick="switchTab('slice')">スライス (M,E,S)</button>
  </div>

  <div class="btn-grid" id="panelCW">
    <button class="cube-btn" onclick="turn('R')">R</button>
    <button class="cube-btn" onclick="turn('L')">L</button>
    <button class="cube-btn" onclick="turn('U')">U</button>
    <button class="cube-btn" onclick="turn('D')">D</button>
    <button class="cube-btn" onclick="turn('F')">F</button>
    <button class="cube-btn" onclick="turn('B')">B</button>
  </div>

  <div class="btn-grid" id="panelCCW" style="display: none;">
    <button class="cube-btn" onclick="turn('R\'')">R'</button>
    <button class="cube-btn" onclick="turn('L\'')">L'</button>
    <button class="cube-btn" onclick="turn('U\'')">U'</button>
    <button class="cube-btn" onclick="turn('D\'')">D'</button>
    <button class="cube-btn" onclick="turn('F\'')">F'</button>
    <button class="cube-btn" onclick="turn('B\'')">B'</button>
  </div>

  <div class="btn-grid" id="panelSlice" style="display: none;">
    <button class="cube-btn" onclick="turn('M')">M</button>
    <button class="cube-btn" onclick="turn('M\'')">M'</button>
    <button class="cube-btn" onclick="turn('E')">E</button>
    <button class="cube-btn" onclick="turn('E\'')">E'</button>
    <button class="cube-btn" onclick="turn('S')">S</button>
    <button class="cube-btn" onclick="turn('S\'')">S'</button>
  </div>

  <div class="util-grid">
    <button class="btn-util" onclick="shuffleCube()">シャッフル</button>
    <button class="btn-util" onclick="resetCube()">リセット</button>
    <button class="btn-util" onclick="resetView()">視点リセット</button>
  </div>

  <div class="status-bar" id="statusBar">Ready</div>
</div>

<script>
  const TILE_SIZE = 32;
  const CUBE_CSS_AXIS_SIGNS = [-1, 1, -1];

  const CUBE_FACE_COLORS = {
    R: '#dc2626',
    L: '#ff7a00',
    U: '#f1eee3',
    D: '#efc85f',
    F: '#5cbd91',
    B: '#5b91d1',
  };

  const FACE_TURNS = {
    R: { face: 'R', axis: 0, slice: 1, direction: -1 },
    L: { face: 'L', axis: 0, slice: -1, direction: 1 },
    U: { face: 'U', axis: 1, slice: 1, direction: -1 },
    D: { face: 'D', axis: 1, slice: -1, direction: 1 },
    F: { face: 'F', axis: 2, slice: 1, direction: -1 },
    B: { face: 'B', axis: 2, slice: -1, direction: 1 },
    M: { face: 'M', axis: 0, slice: 0, direction: 1 },
    E: { face: 'E', axis: 1, slice: 0, direction: 1 },
    S: { face: 'S', axis: 2, slice: 0, direction: -1 },
  };

  // --- 2D 同心円ネットワーク幾何学 ---
  const CX = 290, CY = 195, R_DIST = 46;
  const AXES = [
    { name: 'X', axis: 0, color: '#38bdf8', center: [CX - R_DIST * Math.cos(Math.PI / 6), CY + R_DIST * Math.sin(Math.PI / 6)] },
    { name: 'Y', axis: 1, color: '#10b981', center: [CX, CY - R_DIST] },
    { name: 'Z', axis: 2, color: '#f59e0b', center: [CX + R_DIST * Math.cos(Math.PI / 6), CY + R_DIST * Math.sin(Math.PI / 6)] },
  ];
  const RADII = [130, 105, 80];

  function getIntersectionsOfCircles(c1, r1, c2, r2) {
    const dx = c2[0] - c1[0], dy = c2[1] - c1[1];
    const d = Math.hypot(dx, dy);
    if (d > r1 + r2 || d < Math.abs(r1 - r2) || d === 0) return [];
    const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
    const h = Math.sqrt(Math.max(0, r1 * r1 - a * a));
    const px = c1[0] + (dx * a) / d;
    const py = c1[1] + (dy * a) / d;
    return [
      { x: px + (dy * h) / d, y: py - (dx * h) / d },
      { x: px - (dy * h) / d, y: py + (dx * h) / d },
    ];
  }

  // 54個の交点
  const CIRCLE_INTERSECTIONS = (() => {
    const pts = new Array(54);
    const faces = ['R', 'L', 'U', 'D', 'F', 'B'];
    const getRadIdx = (v) => 1 - v;

    for (let f = 0; f < 6; f++) {
      const face = faces[f];
      for (let slot = 0; slot < 9; slot++) {
        const row = Math.floor(slot / 3), col = slot % 3;
        let x = 0, y = 0, z = 0;
        if (face === 'R') [x, y, z] = [1, 1 - row, 1 - col];
        else if (face === 'L') [x, y, z] = [-1, 1 - row, col - 1];
        else if (face === 'U') [x, y, z] = [col - 1, 1, row - 1];
        else if (face === 'D') [x, y, z] = [col - 1, -1, 1 - row];
        else if (face === 'F') [x, y, z] = [col - 1, 1 - row, 1];
        else if (face === 'B') [x, y, z] = [1 - col, 1 - row, -1];

        let c1, r1, c2, r2, isOuter = false;
        if (face === 'R' || face === 'L') {
          c1 = AXES[1].center; r1 = RADII[getRadIdx(y)];
          c2 = AXES[2].center; r2 = RADII[getRadIdx(z)];
          isOuter = face === 'R';
        } else if (face === 'U' || face === 'D') {
          c1 = AXES[0].center; r1 = RADII[getRadIdx(x)];
          c2 = AXES[2].center; r2 = RADII[getRadIdx(z)];
          isOuter = face === 'U';
        } else {
          c1 = AXES[0].center; r1 = RADII[getRadIdx(x)];
          c2 = AXES[1].center; r2 = RADII[getRadIdx(y)];
          isOuter = face === 'F';
        }

        const hits = getIntersectionsOfCircles(c1, r1, c2, r2);
        if (hits.length === 2) {
          const d0 = Math.hypot(hits[0].x - CX, hits[0].y - CY);
          const d1 = Math.hypot(hits[1].x - CX, hits[1].y - CY);
          pts[f * 9 + slot] = isOuter ? (d0 > d1 ? hits[0] : hits[1]) : (d0 > d1 ? hits[1] : hits[0]);
        } else {
          pts[f * 9 + slot] = { x: CX, y: CY };
        }
      }
    }
    return pts;
  })();

  // 凸包(Convex Hull)計算アルゴリズム
  function getConvexHull(pts) {
    const sorted = pts.slice().sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x));
    const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
    const lower = [];
    for (const p of sorted) {
      while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
      lower.push(p);
    }
    const upper = [];
    for (let i = sorted.length - 1; i >= 0; i--) {
      const p = sorted[i];
      while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
      upper.push(p);
    }
    lower.pop();
    upper.pop();
    return lower.concat(upper);
  }

  function getStickerIndex(pos, norm) {
    const face = norm[0] === 1 ? 'R' : norm[0] === -1 ? 'L' : norm[1] === 1 ? 'U' : norm[1] === -1 ? 'D' : norm[2] === 1 ? 'F' : 'B';
    const faceIdx = ['R', 'L', 'U', 'D', 'F', 'B'].indexOf(face);
    let row, col;
    if (face === 'R') [row, col] = [1 - pos[1], 1 - pos[2]];
    else if (face === 'L') [row, col] = [1 - pos[1], pos[2] + 1];
    else if (face === 'U') [row, col] = [pos[2] + 1, pos[0] + 1];
    else if (face === 'D') [row, col] = [1 - pos[2], pos[0] + 1];
    else if (face === 'F') [row, col] = [1 - pos[1], pos[0] + 1];
    else [row, col] = [1 - pos[1], 1 - pos[0]];
    return faceIdx * 9 + (row * 3 + col);
  }

  // --- 2D 背景レイヤー (半透明面ポリゴン + 同心円) ---
  const FACE_DEFS = [
    { face: 'R', color: CUBE_FACE_COLORS.R },
    { face: 'L', color: CUBE_FACE_COLORS.L },
    { face: 'U', color: CUBE_FACE_COLORS.U },
    { face: 'D', color: CUBE_FACE_COLORS.D },
    { face: 'F', color: CUBE_FACE_COLORS.F },
    { face: 'B', color: CUBE_FACE_COLORS.B },
  ];

  function build2DBaseSVG(activeTurn = null) {
    let svg = '';

    // 1. 各面の半透明ポリゴン (Convex Hull)
    FACE_DEFS.forEach(({ face, color }, faceIdx) => {
      const facePoints = CIRCLE_INTERSECTIONS.slice(faceIdx * 9, (faceIdx + 1) * 9);
      const hull = getConvexHull(facePoints);
      const pointsStr = hull.map(p => `${p.x},${p.y}`).join(' ');

      svg += `<polygon points="${pointsStr}" fill="${color}" fill-opacity="0.12" stroke="${color}" stroke-width="1.8" stroke-dasharray="4 5" stroke-opacity="0.45"/>`;
    });

    // 2. 3軸の同心円群
    AXES.forEach(ax => {
      RADII.forEach((r, idx) => {
        const isTurningRing = activeTurn && activeTurn.axis === ax.axis && (1 - activeTurn.slice) === idx;
        if (isTurningRing) {
          svg += `<circle cx="${ax.center[0]}" cy="${ax.center[1]}" r="${r}" fill="none" stroke="#38bdf8" stroke-width="3" stroke-dasharray="6 6" stroke-opacity="0.9"/>`;
        } else {
          svg += `<circle cx="${ax.center[0]}" cy="${ax.center[1]}" r="${r}" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.2" stroke-dasharray="3 5"/>`;
        }
      });
    });

    return svg;
  }

  // 3D ベクトル回転
  function rotateVector([x, y, z], axis, direction) {
    if (axis === 0) return direction === 1 ? [x, -z, y] : [x, z, -y];
    if (axis === 1) return direction === 1 ? [z, y, -x] : [-z, y, x];
    return direction === 1 ? [-y, x, z] : [y, -x, z];
  }

  function stickerTransform(normal) {
    if (normal[2] === 1) return `translateZ(${TILE_SIZE / 2}px)`;
    if (normal[2] === -1) return `rotateY(180deg) translateZ(${TILE_SIZE / 2}px)`;
    if (normal[0] === 1) return `rotateY(90deg) translateZ(${TILE_SIZE / 2}px)`;
    if (normal[0] === -1) return `rotateY(-90deg) translateZ(${TILE_SIZE / 2}px)`;
    if (normal[1] === 1) return `rotateX(90deg) translateZ(${TILE_SIZE / 2}px)`;
    return `rotateX(-90deg) translateZ(${TILE_SIZE / 2}px)`;
  }

  let pieces = [];
  let isAnimating = false;
  let moveQueue = [];

  function createInitialPieces() {
    pieces = [];
    let id = 0;
    for (let x = -1; x <= 1; x++) {
      for (let y = -1; y <= 1; y++) {
        for (let z = -1; z <= 1; z++) {
          const stickers = [];
          if (x === 1) stickers.push({ color: CUBE_FACE_COLORS.R, normal: [1, 0, 0] });
          if (x === -1) stickers.push({ color: CUBE_FACE_COLORS.L, normal: [-1, 0, 0] });
          if (y === 1) stickers.push({ color: CUBE_FACE_COLORS.U, normal: [0, 1, 0] });
          if (y === -1) stickers.push({ color: CUBE_FACE_COLORS.D, normal: [0, -1, 0] });
          if (z === 1) stickers.push({ color: CUBE_FACE_COLORS.F, normal: [0, 0, 1] });
          if (z === -1) stickers.push({ color: CUBE_FACE_COLORS.B, normal: [0, 0, -1] });

          pieces.push({ id: id++, position: [x, y, z], stickers });
        }
      }
    }
  }

  const cubeWorld = document.getElementById('cubeWorld');
  const svg2D = document.getElementById('svg2D');

  function render3D() {
    cubeWorld.innerHTML = '';
    pieces.forEach(p => {
      cubeWorld.appendChild(createPieceElement(p));
    });
  }

  function createPieceElement(piece) {
    const el = document.createElement('div');
    el.className = 'piece';
    el.style.left = `${(piece.position[0] + 1) * TILE_SIZE}px`;
    el.style.top = `${(1 - piece.position[1]) * TILE_SIZE}px`;
    el.style.transform = `translateZ(${piece.position[2] * TILE_SIZE}px)`;

    piece.stickers.forEach(s => {
      const st = document.createElement('div');
      st.className = 'sticker';
      st.style.backgroundColor = s.color;
      st.style.transform = stickerTransform(s.normal);
      el.appendChild(st);
    });
    return el;
  }

  // 静止状態の2D描画
  function render2DStatic() {
    let dotsHtml = '';
    pieces.forEach(p => {
      p.stickers.forEach(s => {
        const idx = getStickerIndex(p.position, s.normal);
        const pt = CIRCLE_INTERSECTIONS[idx];
        if (pt) {
          dotsHtml += `<circle cx="${pt.x}" cy="${pt.y}" r="8" fill="${s.color}" stroke="#050914" stroke-width="2.2"/>`;
        }
      });
    });
    svg2D.innerHTML = build2DBaseSVG(null) + dotsHtml;
  }

  // --- 2D 円上アニメーション描画関数 ---
  function render2DAnimated(turnInfo, progress) {
    const { axis, slice, direction } = turnInfo;
    let dotsHtml = '';

    pieces.forEach(p => {
      const isMoving = p.position[axis] === slice;

      p.stickers.forEach(s => {
        const baseIdx = getStickerIndex(p.position, s.normal);
        const basePos = CIRCLE_INTERSECTIONS[baseIdx];
        if (!basePos) return;

        let x = basePos.x;
        let y = basePos.y;

        if (isMoving) {
          const nextPos3D = rotateVector(p.position, axis, direction);
          const nextNormal3D = rotateVector(s.normal, axis, direction);
          const targetIndex = getStickerIndex(nextPos3D, nextNormal3D);
          const targetPos = CIRCLE_INTERSECTIONS[targetIndex];

          if (targetPos) {
            // 回転中心の特定 (面心 or 軸心)
            let rotCenter;
            if (Math.abs(s.normal[axis]) === 1) {
              const faceCenterPos = [0, 0, 0];
              faceCenterPos[axis] = s.normal[axis];
              const centerIdx = getStickerIndex(faceCenterPos, s.normal);
              rotCenter = CIRCLE_INTERSECTIONS[centerIdx];
            } else {
              const axCenter = AXES[axis].center;
              rotCenter = { x: axCenter[0], y: axCenter[1] };
            }

            // 円上での角度・半径補間
            const dx0 = basePos.x - rotCenter.x;
            const dy0 = basePos.y - rotCenter.y;
            const r0 = Math.hypot(dx0, dy0);
            const a0 = Math.atan2(dy0, dx0);

            const dx1 = targetPos.x - rotCenter.x;
            const dy1 = targetPos.y - rotCenter.y;
            const r1 = Math.hypot(dx1, dy1);
            const a1 = Math.atan2(dy1, dx1);

            let diff = a1 - a0;
            while (diff > Math.PI) diff -= Math.PI * 2;
            while (diff < -Math.PI) diff += Math.PI * 2;

            const curAngle = a0 + diff * progress;
            const curR = r0 + (r1 - r0) * progress;

            x = rotCenter.x + curR * Math.cos(curAngle);
            y = rotCenter.y + curR * Math.sin(curAngle);
          }
        }

        dotsHtml += `<circle cx="${x}" cy="${y}" r="8" fill="${s.color}" stroke="${isMoving ? '#f8fafc' : '#050914'}" stroke-width="${isMoving ? '2.8' : '2.2'}"/>`;
      });
    });

    svg2D.innerHTML = build2DBaseSVG(turnInfo) + dotsHtml;
  }

  // カメラ視点制御
  let rotX = -24, rotY = -32;
  const sceneContainer = document.getElementById('sceneContainer');

  function updateCamera() {
    cubeWorld.style.transform = `rotateX(${rotX}deg) rotateY(${rotY}deg)`;
  }
  updateCamera();

  let isDragging = false, prevX = 0, prevY = 0;
  sceneContainer.addEventListener('pointerdown', (e) => {
    isDragging = true; prevX = e.clientX; prevY = e.clientY;
    sceneContainer.setPointerCapture(e.pointerId);
  });
  sceneContainer.addEventListener('pointermove', (e) => {
    if (!isDragging) return;
    rotY += (e.clientX - prevX) * 0.6;
    rotX = Math.max(-85, Math.min(85, rotX - (e.clientY - prevY) * 0.6));
    updateCamera();
    prevX = e.clientX; prevY = e.clientY;
  });
  const stopDrag = () => { isDragging = false; };
  sceneContainer.addEventListener('pointerup', stopDrag);
  sceneContainer.addEventListener('pointercancel', stopDrag);

  function resetView() {
    rotX = -24; rotY = -32;
    updateCamera();
  }

  // 回転処理(3Dと2Dのアニメーション完全連動)
  function turn(move) {
    moveQueue.push(move);
    processQueue();
  }

  function processQueue() {
    if (isAnimating || moveQueue.length === 0) return;
    isAnimating = true;

    const move = moveQueue.shift();
    document.getElementById('statusBar').innerText = 'Action: ' + move;

    const isInverse = move.endsWith("'");
    const baseMove = move.replace("'", "");
    const baseInfo = FACE_TURNS[baseMove];
    const direction = isInverse ? -baseInfo.direction : baseInfo.direction;
    const axis = baseInfo.axis;
    const slice = baseInfo.slice;
    const turnInfo = { axis, slice, direction };

    // --- 1. 3D 回転のセットアップ ---
    const turningPieces = pieces.filter(p => p.position[axis] === slice);
    const nonTurningPieces = pieces.filter(p => p.position[axis] !== slice);

    cubeWorld.innerHTML = '';
    nonTurningPieces.forEach(p => cubeWorld.appendChild(createPieceElement(p)));

    const turnGroup = document.createElement('div');
    turnGroup.className = 'turn-group';
    turningPieces.forEach(p => turnGroup.appendChild(createPieceElement(p)));
    cubeWorld.appendChild(turnGroup);

    const angle = CUBE_CSS_AXIS_SIGNS[axis] * direction * 90;
    const transformStr = axis === 0 ? `rotateX(${angle}deg)` : axis === 1 ? `rotateY(${angle}deg)` : `rotateZ(${angle}deg)`;

    const DURATION = 220; // アニメーション時間 (ms)
    requestAnimationFrame(() => {
      turnGroup.style.transition = `transform ${DURATION}ms cubic-bezier(0.25, 0.1, 0.25, 1)`;
      turnGroup.style.transform = transformStr;
    });

    // --- 2. 2D 円上アニメーションの実行 (requestAnimationFrame) ---
    const startTime = performance.now();

    function step2D(now) {
      const elapsed = now - startTime;
      const rawProgress = Math.min(1, elapsed / DURATION);
      // イージング
      const progress = rawProgress < 0.5 
        ? 2 * rawProgress * rawProgress 
        : 1 - Math.pow(-2 * rawProgress + 2, 2) / 2;

      render2DAnimated(turnInfo, progress);

      if (rawProgress < 1) {
        requestAnimationFrame(step2D);
      } else {
        // --- 3. アニメーション終了時の幾何データ更新 ---
        pieces = pieces.map(p => {
          if (p.position[axis] !== slice) return p;
          return {
            ...p,
            position: rotateVector(p.position, axis, direction),
            stickers: p.stickers.map(s => ({
              ...s,
              normal: rotateVector(s.normal, axis, direction),
            })),
          };
        });

        render3D();
        render2DStatic();
        isAnimating = false;
        processQueue();
      }
    }

    requestAnimationFrame(step2D);
  }

  function shuffleCube() {
    if (isAnimating) return;
    const moves = ['R', 'L', 'U', 'D', 'F', 'B'];
    for (let i = 0; i < 15; i++) {
      const m = moves[Math.floor(Math.random() * moves.length)];
      const inv = Math.random() > 0.5 ? "'" : "";
      moveQueue.push(m + inv);
    }
    processQueue();
  }

  function resetCube() {
    if (isAnimating) return;
    moveQueue = [];
    document.getElementById('statusBar').innerText = 'Reset Done';
    createInitialPieces();
    render3D();
    render2DStatic();
  }

  function setViewMode(mode) {
    document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
    const sc = document.getElementById('sceneContainer');
    const nc = document.getElementById('networkContainer');

    if (mode === 'dual') {
      document.getElementById('btnDual').classList.add('active');
      sc.style.display = 'flex';
      nc.style.display = 'flex';
    } else if (mode === '3d') {
      document.getElementById('btn3D').classList.add('active');
      sc.style.display = 'flex';
      nc.style.display = 'none';
    } else {
      document.getElementById('btn2D').classList.add('active');
      sc.style.display = 'none';
      nc.style.display = 'flex';
    }
  }

  function switchTab(type) {
    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
    document.getElementById('panelCW').style.display = 'none';
    document.getElementById('panelCCW').style.display = 'none';
    document.getElementById('panelSlice').style.display = 'none';

    if (type === 'cw') {
      document.getElementById('tabCW').classList.add('active');
      document.getElementById('panelCW').style.display = 'grid';
    } else if (type === 'ccw') {
      document.getElementById('tabCCW').classList.add('active');
      document.getElementById('panelCCW').style.display = 'grid';
    } else {
      document.getElementById('tabSlice').classList.add('active');
      document.getElementById('panelSlice').style.display = 'grid';
    }
  }

  // 初期化実行
  createInitialPieces();
  render3D();
  render2DStatic();
</script>
</body>
</html>

実際に動かしてみて分かった面白さ

  1. 裏側の面がすべて同時に可視化される
    3Dキューブでは、視点を回転させないと背面の状況がわかりませんが、2Dネットワーク側では全54マスが常に視界内にあります。
  2. ルービックキューブは「3つの同心円リングの置換」であると体感できる
    2D側だけで操作していると、パズルというよりも「3つのギアが噛み合って玉が環状軌道を回っているゲーム」のような感覚を味わえます。展開図では断絶していたステッカーの移動が、滑らかな円軌道として繋がるため、群論における軌道の感覚が直感的に掴めるのが面白いところです。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?