前回、Three.js 備忘録を作ったので忘れないうちにルービックキューブを作ってみることにしました。
Contents
HTML部分
最初にHTML部分を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <title>鳩でもわかるルービックキューブ</title> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <h1>鳩でもわかるルービックキューブ</h1> <div id = time></div> <canvas id="canvas"></canvas> <div id="ctrl-buttons"> <div class="row-ctrl-buttons"> <button id = "rotate-left" class = "ctrl-button">左側を回転</button> <button id = "rotate-right" class = "ctrl-button">右側を回転</button> </div> <div class="row-ctrl-buttons"> <button id = "rotate-top" class = "ctrl-button">上側を回転</button> <button id = "rotate-bottom" class = "ctrl-button">下側を回転</button> </div> <div class="row-ctrl-buttons"> <button id = "rotate-front" class = "ctrl-button">手前を回転</button> <button id = "rotate-back" class = "ctrl-button">奥側を回転</button> </div> <div class="row-ctrl-buttons"> <button id = "rotate-x" class = "ctrl-button">全体をX軸を中心に回転</button> <button id = "rotate-y" class = "ctrl-button">全体をY軸を中心に回転</button> </div> </div> <div id="start-buttons"> <button id = "easy" class = "start-button">EASY</button> <button id = "medium" class = "start-button">MEDIUM</button> <button id = "hard" class = "start-button">HARD</button> </div> <div id = "volume-ctrl"></div> </div> <script type="module" src="./index.js"></script> </body> </html> |
style.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
body { background-color: black; color: white; } #container { width: 360px; } h1 { font-size: 20px; text-align: center; margin-bottom: 1px; } #time { font-weight: bold; font-size: 18px; text-align: center; margin-bottom: 0px; } #canvas { display: block; margin-bottom: 10px; } .ctrl-button { width: 140px; height: 50px; } .start-button { width: 100px; height: 50px; } .row-ctrl-buttons { margin-top: 10px; } #ctrl-buttons, #start-buttons { text-align: center; } #ctrl-buttons { display: none; } #start-buttons { display: block; } #volume-ctrl { margin-top: 20px; } |
グローバル変数と定数
グローバル変数と定数を示します。
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// DOM要素 const $startButtons = document.getElementById('start-buttons'); const $ctrlButtons = document.getElementById('ctrl-buttons'); const $time = document.getElementById('time'); let noMeve = true; // 回転処理中に別の回転処理がおきないようにする let time = 0; // 消費時間 let intervalIDs = []; // setInterval関数を利用した繰り返し動作取り消しのために必要なIDの配列 // 効果音 const soundMove = new Audio('./sounds/move.mp3'); const soundClear = new Audio('./sounds/clear.mp3'); const sounds = [soundMove, soundClear]; let volume = 0.3; // ボリューム import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.module.js'; // canvasサイズと3Dオブジェクトのサイズ const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 360; const CUBE_SIZE = 24; // レンダラー、シーン、カメラを作成 const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), }); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-60, +60, 60, -60, 1, 1000); // 平行投影 // ルービックキューブを構成するキュープを格納する配列 let cubes = []; |
ページが読み込まれたときの処理
ページが読み込まれたときにおこなわれる処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
window.onload = () => { // レンダラーとカメラの初期設定 renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(CANVAS_WIDTH, CANVAS_HEIGHT); camera.position.set(80, 80, 150); camera.lookAt(0, 0, 0); addEventListeners(); // イベントリスナの追加(後述) addObjects(); // 光源とキューブの追加(後述) // 更新処理の開始 const INTERVAL = 1000 / 60; let nextUpdateTime = new Date().getTime() + INTERVAL; frameProc(); function frameProc(){ const curTime = new Date().getTime(); if(nextUpdateTime < curTime){ nextUpdateTime += INTERVAL; renderer.render(scene, camera); // レンダリング } requestAnimationFrame(() => frameProc()); } // レンジスライダーでボリューム設定可能にする(後述) initVolume('volume-ctrl', sounds); } |
イベントリスナの追加
ボタンがクリックされたらゲーム開始の処理やキューブの回転処理がおこなわれるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// rotate関数とgameStart関数は後述 function addEventListeners(){ document.getElementById('rotate-left')?.addEventListener('click', async() => await rotate('left', false, true)); document.getElementById('rotate-right')?.addEventListener('click', async() => await rotate('right', false, true)); document.getElementById('rotate-top')?.addEventListener('click', async() => await rotate('top', false, true)); document.getElementById('rotate-bottom')?.addEventListener('click', async() => await rotate('bottom', false, true)); document.getElementById('rotate-front')?.addEventListener('click', async() => await rotate('front', false, true)); document.getElementById('rotate-back')?.addEventListener('click', async() => await rotate('back', false, true)); document.getElementById('rotate-x')?.addEventListener('click', async() => await rotate('all-x', false, true)); document.getElementById('rotate-y')?.addEventListener('click', async() => await rotate('all-y', false, true)); document.getElementById('easy')?.addEventListener('click', async() => await gameStart(3)); document.getElementById('medium')?.addEventListener('click', async() => await gameStart(8)); document.getElementById('hard')?.addEventListener('click', async() => await gameStart(16)); } |
光源とキューブの追加
光源とキューブの追加する処理を示します。
キューブは立方体の3Dオブジェクトに色がついた板を結合させたものです。平面だと見た目がイマイチなので低い錐台形にしています。これを全部で3×3×3個生成して適切な位置に移動します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function addObjects(){ // 環境光 const ambientLight = new THREE.AmbientLight(0xFFFFFF, 1.0); scene.add(ambientLight); // 平行光源 const directionalLight = new THREE.DirectionalLight(0xffffff, 2.0); directionalLight.position.set(80, 100, 150); scene.add(directionalLight); const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE); const material = new THREE.MeshPhongMaterial( {color: 0x333333, side: THREE.DoubleSide} ); for(let depth = 0; depth < 3; depth++){ for(let row = 0; row < 3; row++){ for(let col = 0; col < 3; col++){ const cube = new THREE.Group(); const box = new THREE.Mesh(geometry, material); cube.add(box); // 色がついた平面に近い錐台形を生成して立方体の表面に貼り付ける // 上面:シアン、下面:マゼンタ、左面:赤、右面:緑、前面:青、背面:黄 if(row == 0) addSurfaceToCube(cube, 'top', 0x00ffff); // 後述 if(row == 2) addSurfaceToCube(cube, 'bottom', 0xff00ff); if(col == 0) addSurfaceToCube(cube, 'left', 0xff0000); if(col == 2) addSurfaceToCube(cube, 'right', 0x00ff00); if(depth == 0) addSurfaceToCube(cube, 'front', 0x0000ff); if(depth == 2) addSurfaceToCube(cube, 'back', 0xffff00); addCubeToScene(cube, depth, row, col); // 後述 } } } } |
色がついた平面に近い錐台形を生成して立方体の表面に貼り付ける処理を示します。錐台形を生成して回転、平行移動させたあとcubeに追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
function addSurfaceToCube(cube, where, color){ const material = new THREE.MeshPhongMaterial( {color: color, side: THREE.DoubleSide} ); const bufferGeometry = new THREE.BufferGeometry(); const cubeHalfSize = CUBE_SIZE / 2; const vertices = new Float32Array( [ cubeHalfSize, cubeHalfSize, 0, cubeHalfSize, -cubeHalfSize, 0, -cubeHalfSize, -cubeHalfSize, 0, -cubeHalfSize, cubeHalfSize, 0, cubeHalfSize - 2, cubeHalfSize - 2, 4, cubeHalfSize - 2, -cubeHalfSize + 2, 4, -cubeHalfSize + 2, -cubeHalfSize + 2, 4, -cubeHalfSize + 2, cubeHalfSize - 2, 4, ] ); bufferGeometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) ); const indexes = new Uint32Array([ 0, 1, 4, // 時計まわりに指定する 1, 5, 4, 1, 2, 5, 2, 6, 5, 2, 3, 6, 3, 7, 6, 0, 4, 3, 3, 4, 7, 4, 5, 7, 5, 6, 7, ]) bufferGeometry.setIndex(new THREE.BufferAttribute( indexes, 1 )); bufferGeometry.computeVertexNormals(); // これを呼び出さないとライトが効かない const mesh = new THREE.Mesh( bufferGeometry, material ); if(where == "front"){ mesh.position.set(0, 0, cubeHalfSize); } if(where == "back"){ mesh.rotation.y = Math.PI; mesh.position.set(0, 0, -cubeHalfSize); } if(where == "left"){ mesh.rotation.y = Math.PI / 2 * 3; mesh.position.set(-cubeHalfSize, 0, 0); } if(where == "right"){ mesh.rotation.y = Math.PI / 2; mesh.position.set(cubeHalfSize, 0, 0); } if(where == "top"){ mesh.rotation.x = Math.PI / 2 * 3; mesh.position.set(0, cubeHalfSize, 0); } if(where == "bottom"){ mesh.rotation.x = Math.PI / 2; mesh.position.set(0, -cubeHalfSize, 0); } cube.add(mesh); } |
cubeをsceneに追加する処理を示します。
cubeが初期位置の上から左から手前から何番目にあるのかを指定すればsceneにおける初期座標が決まります。cubeをその位置に移動させてsceneに追加します。また同じ3Dオブジェクトを配列cubesにも追加しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 |
function addCubeToScene(cube, depth, row, col){ // 第二~第四引数からcubeの初期座標を求める const values1 = [-CUBE_SIZE -1, 0, CUBE_SIZE +1]; const values2 = [CUBE_SIZE+1, 0, -CUBE_SIZE -1]; let x = values1[col]; let y = values2[row]; let z = values2[depth]; cube.position.set(x, y, z); scene.add(cube); cubes.push(cube); } |
ボリューム設定可能に
レンジスライダーでボリューム設定可能にする処理を示します。これは定番の処理です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
function initVolume(elementId, sounds){ const $element = document.getElementById(elementId); const $div = document.createElement('div'); const $span1 = document.createElement('span'); $span1.innerHTML = '音量:'; $div?.appendChild($span1); const $range = document.createElement('input'); $range.type = 'range'; $div?.appendChild($range); const $span2 = document.createElement('span'); $div?.appendChild($span2); $range.addEventListener('input', () => { const value = $range.value; $span2.innerText = value; volume = value / 100; setVolume(); }); setVolume(); $span2.innerText = volume * 100; $span2.style.marginLeft = '16px'; $range.value = volume * 100; $range.style.width = '250px'; $range.style.verticalAlign = 'middle'; $element?.appendChild($div); const $button = document.createElement('button'); $button.innerHTML = '音量テスト'; $button.style.width = '120px'; $button.style.height = '45px'; $button.style.marginTop = '12px'; $button.style.marginLeft = '32px'; $button.addEventListener('click', () => { sounds[0].currentTime = 0; sounds[0].play(); }); $element?.appendChild($button); function setVolume(){ for(let i = 0; i < sounds.length; i++) sounds[i].volume = volume; } } |
ゲーム開始の処理
ゲームを開始するための処理を示します。効果音、消費時間のリセット、スタートボタンの非表示などの処理が終わったら問題をつくります。引数は初期状態からの回転の回数です。
‘front’, ‘back’, ‘left’, ‘right’, ‘top’, ‘bottom’をランダムに組み合わせて回転させるのですが、Easyは3回しか回転させないのであまりに簡単な問題にならないように、最初の3回は同じものが重ならないようにしています。
回転の種類と順番が決まったら実際に回転させます。そしてタイマーのカウントアップを開始します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
async function gameStart(shuffleCount){ time = 0; soundMove.currentTime = 0; soundMove.play(); $startButtons.style.display = 'none'; // 問題をつくる const arr = []; // 最初の3回は同じものが重ならないようにする(簡単すぎないようにする) const arr1 = [['front', 'back'], ['left', 'right'], ['top', 'bottom']]; while(arr1.length > 0 && arr.length < shuffleCount){ const idx = Math.floor(Math.random() * arr1.length); if(Math.random() < 0.5) arr.push(arr1[idx][0]); else arr.push(arr1[idx][1]); arr1.splice(idx, 1) } const arr2 = ['front', 'back', 'left', 'right', 'top', 'bottom']; for(let i=arr.length; i<shuffleCount; i++){ const idx = Math.floor(Math.random() * arr2.length); arr.push(arr2[idx]); } // 回転の種類と順番が決まったら実際に回転させる noMeve = false; // cubeを回転不能にするフラグをクリア for(let i=0; i<arr.length; i++) await rotate(arr[i], true, false); // 後述 $ctrlButtons.style.display = 'block'; // タイマーのカウントアップ開始 const id0 = setInterval(() => { time++; const seconds = time % 60; const minutes = Math.floor(time / 60); const h = Math.floor(minutes / 60); const m = minutes % 60 >= 10 ? minutes % 60 : '0' + minutes % 60; const s = seconds >= 10 ? seconds : '0' + seconds; $time.innerHTML = `経過時間 ${h}:${m}:${s}`; }, 1000); // ユーザーを焦らせるために消費時間をピカピカさせる let red = true; const id1 = setInterval(() => { red = !red; $time.style.color = red ? '#ff4500' : '#ffff00'; }, 300); // タイマーを停止させるときにclearInterval関数にわたす引数を保存する intervalIDs.push(id0); intervalIDs.push(id1); } |
キューブの回転
キューブを回転させる処理を示します。
回転させたいキューブをgroupに追加します。そしてgroupごと回転させます。第三引数がtrueの場合、16回の更新で90度回転させます。falseの場合はただちに回転させます(問題生成のときはアニメーションさせる必要がない)。
回転処理が終わったらsceneからgroupを削除しますが、group内の3Dオブジェクトをsceneに追加しなおす処理が必要です。このとき回転による座標のズレの補正もしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
async function rotate(where, reverse, wait){ if(noMeve) return; if(wait){ soundMove.currentTime = 0; soundMove.play(); } noMeve = true; // 回転させたい3Dオブジェクト(キューブ)を集める let targets = []; if(where == 'top') targets = cubes.filter(cube => cube.position.y > CUBE_SIZE / 2); if(where == 'bottom') targets = cubes.filter(cube => cube.position.y < -CUBE_SIZE / 2); if(where == 'left') targets = cubes.filter(cube => cube.position.x < -CUBE_SIZE / 2); if(where == 'right') targets = cubes.filter(cube => cube.position.x > CUBE_SIZE / 2); if(where == 'front') targets = cubes.filter(cube => cube.position.z > CUBE_SIZE / 2); if(where == 'back') targets = cubes.filter(cube => cube.position.z < -CUBE_SIZE / 2); if(where == 'all-x' || where == 'all-y') targets = cubes; // グループに回転させたいキューブを追加 const group = new THREE.Group(); scene.add(group); for(let i=0; i<targets.length; i++) group.add(targets[i]); // 16回の更新で90度回転させる if(wait){ const count = 16; const angle = reverse == false ? Math.PI / 2 / count : -Math.PI / 2 / count; for(let i= 0; i < count; i++){ await new Promise(resolve => setTimeout(resolve, 1000 / 60)); if(where == 'left' || where == 'right' || where == 'all-x') group.rotation.x += angle; if(where == 'top' || where == 'bottom' || where == 'all-y') group.rotation.y += angle; if(where == 'front' || where == 'back') group.rotation.z += angle; } } else { const angle90 = reverse == false ? Math.PI / 2 : -Math.PI / 2; if(where == 'left' || where == 'right') group.rotation.x += angle90; if(where == 'top' || where == 'bottom') group.rotation.y += angle90; if(where == 'front' || where == 'back') group.rotation.z += angle90; } // 3Dオブジェクトをsceneに追加しなおす for(let i=0; i<targets.length; i++){ const worldPosition = targets[i].getWorldPosition(new THREE.Vector3()); const worldQuaternion = targets[i].getWorldQuaternion(new THREE.Quaternion()); group.remove(targets[i]); // 回転によるズレの補正(回転後の位置の座標は整数値になることを利用して四捨五入で対応する) targets[i].position.set(Math.round(worldPosition.x), Math.round(worldPosition.y), Math.round(worldPosition.z)); targets[i].setRotationFromQuaternion(worldQuaternion); // 回転によるズレの補正(角度) let rx = targets[i].rotation.x; let ry = targets[i].rotation.y; let rz = targets[i].rotation.z; rx = Math.round(rx / (Math.PI / 2)) * (Math.PI / 2); ry = Math.round(ry / (Math.PI / 2)) * (Math.PI / 2); rz = Math.round(rz / (Math.PI / 2)) * (Math.PI / 2); targets[i].rotation.set(rx, ry, rz); scene.add(targets[i]); } scene.remove(group); if(check()) // ゲームクリア判定(後述) onGameCleared(); noMeve = false; } |
ゲームクリア時の処理
ゲームクリア判定の処理を示します。
ここでやっているのは各キューブの向きを調べています。初期状態ではキューブは同じ方向を向いています。回転させることでキューブの向きが変わります。すべて揃っているのであればすべてのキューブは同じ方向を向いているはずです。
rotation.xは -π以上π未満です。この値にπを足したものを π / 2 で割り四捨五入すると 0 ~ 3 の整数が得られます(小数点以下X桁の誤差対策)。これを比較することですべてのキューブは同じ方向を向いているかどうかがわかります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function check(){ const irx0 = Math.round((cubes[0].rotation.x + Math.PI) / (Math.PI / 2)); const iry0 = Math.round((cubes[0].rotation.y + Math.PI) / (Math.PI / 2)); const irz0 = Math.round((cubes[0].rotation.z + Math.PI) / (Math.PI / 2)); for(let i=1; i<cubes.length; i++){ const irx = Math.round((cubes[i].rotation.x + Math.PI) / (Math.PI / 2)); const iry = Math.round((cubes[i].rotation.y + Math.PI) / (Math.PI / 2)); const irz = Math.round((cubes[i].rotation.z + Math.PI) / (Math.PI / 2)); if(irx0 % 4 != irx % 4 || iry0 % 4 != iry % 4 || irz0 % 4 != irz % 4) return false; } return true; } |
ゲームクリア時の処理を示します。noMeve = trueにしてキューブがこれ以上回転しないようにしたあとsetInterval関数で開始したタイマー処理を停止させたあと、効果音の再生、スタートボタンの再表示などの処理をおこなっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function onGameCleared(){ noMeve = true; for(let i=0; i<intervalIDs.length; i++) clearInterval(intervalIDs[i]); $time.style.color = '#ffff00'; setTimeout(() => { $startButtons.style.display = 'block'; $ctrlButtons.style.display = 'none'; soundClear.currentTime = 0; soundClear.play(); }, 500); } |