はじめに
ルービックキューブ(3×3×3)は通常、3次元空間の立体として解釈・操作されます。一般的な平面表現といえば十字型の展開図ですが、展開図上では 「面を回したときにピースがどの軌道を通って隣の面に移るのか」という連続的な回転運動 が途切れてしまい、直感的に追うのが難しくなります。
そこで今回、「3次元の回転対称性を2次元平面上の同心円ネットワークに射影(次元削減)し、3Dと2Dがリアルタイムに完全連動するシミュレーター」 をHTML/CSS/JavaScript(外部ライブラリなし、単一ファイル)で実装してみました。
【関連動画】
【埋め込み済みコード】
サンプル画像
ソースコード
コードを展開する (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>
実際に動かしてみて分かった面白さ
-
裏側の面がすべて同時に可視化される
3Dキューブでは、視点を回転させないと背面の状況がわかりませんが、2Dネットワーク側では全54マスが常に視界内にあります。 -
ルービックキューブは「3つの同心円リングの置換」であると体感できる
2D側だけで操作していると、パズルというよりも「3つのギアが噛み合って玉が環状軌道を回っているゲーム」のような感覚を味わえます。展開図では断絶していたステッカーの移動が、滑らかな円軌道として繋がるため、群論における軌道の感覚が直感的に掴めるのが面白いところです。
