├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── lib ├── OrbitControls.js ├── jquery-2.0.3.js ├── three.js ├── threex.domevent.js └── threex.domevent.object3d.js ├── rubik.js └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | Notes 2 | *~ 3 | *.tmp 4 | tmp 5 | tmp* 6 | *.sh 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 jwhitfieldseed 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rubik-js 2 | A WebGL Rubik's cube. 3 | 4 | [See it in action](http://joews.github.io/rubik-js/). It requires a WebGL-capable browser. 5 | 6 | Features 7 | 8 | * Shuffle 9 | * Click and drag plane rotation 10 | * Solve (by replaying moves in reverse) 11 | 12 | This was a learning exercise in [Three.js](http://threejs.org/), and there are plenty of rough edges. TODO list: 13 | 14 | * Better lighting and materials 15 | * Support for non-WebGL browsers 16 | * A general solver algorithm 17 | * Awareness of puzzle completion, possibly with some celebratory 3D effects 18 | * Touch support 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Rubik Cube 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Fork me on GitHub 15 | 16 | 31 | 32 |
33 | 34 |

35 | Built with Three.js by Joe Whitfield-Seed 2013. 36 |

37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /lib/OrbitControls.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author qiao / https://github.com/qiao 3 | * @author mrdoob / http://mrdoob.com 4 | * @author alteredq / http://alteredqualia.com/ 5 | * @author WestLangley / http://github.com/WestLangley 6 | * @author erich666 / http://erichaines.com 7 | */ 8 | /*global THREE, console */ 9 | 10 | // This set of controls performs orbiting, dollying (zooming), and panning. It maintains 11 | // the "up" direction as +Y, unlike the TrackballControls. Touch on tablet and phones is 12 | // supported. 13 | // 14 | // Orbit - left mouse / touch: one finger move 15 | // Zoom - middle mouse, or mousewheel / touch: two finger spread or squish 16 | // Pan - right mouse, or arrow keys / touch: three finter swipe 17 | // 18 | // This is a drop-in replacement for (most) TrackballControls used in examples. 19 | // That is, include this js file and wherever you see: 20 | // controls = new THREE.TrackballControls( camera ); 21 | // controls.target.z = 150; 22 | // Simple substitute "OrbitControls" and the control should work as-is. 23 | 24 | THREE.OrbitControls = function ( object, domElement ) { 25 | 26 | this.object = object; 27 | this.domElement = ( domElement !== undefined ) ? domElement : document; 28 | 29 | // API 30 | 31 | // Set to false to disable this control 32 | this.enabled = true; 33 | 34 | // "target" sets the location of focus, where the control orbits around 35 | // and where it pans with respect to. 36 | this.target = new THREE.Vector3(); 37 | // center is old, deprecated; use "target" instead 38 | this.center = this.target; 39 | 40 | // This option actually enables dollying in and out; left as "zoom" for 41 | // backwards compatibility 42 | this.noZoom = false; 43 | this.zoomSpeed = 1.0; 44 | // Limits to how far you can dolly in and out 45 | this.minDistance = 0; 46 | this.maxDistance = Infinity; 47 | 48 | // Set to true to disable this control 49 | this.noRotate = false; 50 | this.rotateSpeed = 1.0; 51 | 52 | // Set to true to disable this control 53 | this.noPan = false; 54 | this.keyPanSpeed = 7.0; // pixels moved per arrow key push 55 | 56 | // Set to true to automatically rotate around the target 57 | this.autoRotate = false; 58 | this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60 59 | 60 | // How far you can orbit vertically, upper and lower limits. 61 | // Range is 0 to Math.PI radians. 62 | this.minPolarAngle = 0; // radians 63 | this.maxPolarAngle = Math.PI; // radians 64 | 65 | // Set to true to disable use of the keys 66 | this.noKeys = false; 67 | // The four arrow keys 68 | this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 }; 69 | 70 | //////////// 71 | // internals 72 | 73 | var scope = this; 74 | 75 | var EPS = 0.000001; 76 | 77 | var rotateStart = new THREE.Vector2(); 78 | var rotateEnd = new THREE.Vector2(); 79 | var rotateDelta = new THREE.Vector2(); 80 | 81 | var panStart = new THREE.Vector2(); 82 | var panEnd = new THREE.Vector2(); 83 | var panDelta = new THREE.Vector2(); 84 | 85 | var dollyStart = new THREE.Vector2(); 86 | var dollyEnd = new THREE.Vector2(); 87 | var dollyDelta = new THREE.Vector2(); 88 | 89 | var phiDelta = 0; 90 | var thetaDelta = 0; 91 | var scale = 1; 92 | var pan = new THREE.Vector3(); 93 | 94 | var lastPosition = new THREE.Vector3(); 95 | 96 | var STATE = { NONE : -1, ROTATE : 0, DOLLY : 1, PAN : 2, TOUCH_ROTATE : 3, TOUCH_DOLLY : 4, TOUCH_PAN : 5 }; 97 | var state = STATE.NONE; 98 | 99 | // events 100 | 101 | var changeEvent = { type: 'change' }; 102 | 103 | 104 | this.rotateLeft = function ( angle ) { 105 | 106 | if ( angle === undefined ) { 107 | 108 | angle = getAutoRotationAngle(); 109 | 110 | } 111 | 112 | thetaDelta -= angle; 113 | 114 | }; 115 | 116 | this.rotateUp = function ( angle ) { 117 | 118 | if ( angle === undefined ) { 119 | 120 | angle = getAutoRotationAngle(); 121 | 122 | } 123 | 124 | phiDelta -= angle; 125 | 126 | }; 127 | 128 | // pass in distance in world space to move left 129 | this.panLeft = function ( distance ) { 130 | 131 | var panOffset = new THREE.Vector3(); 132 | var te = this.object.matrix.elements; 133 | // get X column of matrix 134 | panOffset.set( te[0], te[1], te[2] ); 135 | panOffset.multiplyScalar(-distance); 136 | 137 | pan.add( panOffset ); 138 | 139 | }; 140 | 141 | // pass in distance in world space to move up 142 | this.panUp = function ( distance ) { 143 | 144 | var panOffset = new THREE.Vector3(); 145 | var te = this.object.matrix.elements; 146 | // get Y column of matrix 147 | panOffset.set( te[4], te[5], te[6] ); 148 | panOffset.multiplyScalar(distance); 149 | 150 | pan.add( panOffset ); 151 | }; 152 | 153 | // main entry point; pass in Vector2 of change desired in pixel space, 154 | // right and down are positive 155 | this.pan = function ( delta ) { 156 | 157 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement; 158 | 159 | if ( scope.object.fov !== undefined ) { 160 | 161 | // perspective 162 | var position = scope.object.position; 163 | var offset = position.clone().sub( scope.target ); 164 | var targetDistance = offset.length(); 165 | 166 | // half of the fov is center to top of screen 167 | targetDistance *= Math.tan( (scope.object.fov/2) * Math.PI / 180.0 ); 168 | // we actually don't use screenWidth, since perspective camera is fixed to screen height 169 | scope.panLeft( 2 * delta.x * targetDistance / element.clientHeight ); 170 | scope.panUp( 2 * delta.y * targetDistance / element.clientHeight ); 171 | 172 | } else if ( scope.object.top !== undefined ) { 173 | 174 | // orthographic 175 | scope.panLeft( delta.x * (scope.object.right - scope.object.left) / element.clientWidth ); 176 | scope.panUp( delta.y * (scope.object.top - scope.object.bottom) / element.clientHeight ); 177 | 178 | } else { 179 | 180 | // camera neither orthographic or perspective - warn user 181 | console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); 182 | 183 | } 184 | 185 | }; 186 | 187 | this.dollyIn = function ( dollyScale ) { 188 | 189 | if ( dollyScale === undefined ) { 190 | 191 | dollyScale = getZoomScale(); 192 | 193 | } 194 | 195 | scale /= dollyScale; 196 | 197 | }; 198 | 199 | this.dollyOut = function ( dollyScale ) { 200 | 201 | if ( dollyScale === undefined ) { 202 | 203 | dollyScale = getZoomScale(); 204 | 205 | } 206 | 207 | scale *= dollyScale; 208 | 209 | }; 210 | 211 | this.update = function () { 212 | 213 | var position = this.object.position; 214 | var offset = position.clone().sub( this.target ); 215 | 216 | // angle from z-axis around y-axis 217 | 218 | var theta = Math.atan2( offset.x, offset.z ); 219 | 220 | // angle from y-axis 221 | 222 | var phi = Math.atan2( Math.sqrt( offset.x * offset.x + offset.z * offset.z ), offset.y ); 223 | 224 | if ( this.autoRotate ) { 225 | 226 | this.rotateLeft( getAutoRotationAngle() ); 227 | 228 | } 229 | 230 | theta += thetaDelta; 231 | phi += phiDelta; 232 | 233 | // restrict phi to be between desired limits 234 | phi = Math.max( this.minPolarAngle, Math.min( this.maxPolarAngle, phi ) ); 235 | 236 | // restrict phi to be betwee EPS and PI-EPS 237 | phi = Math.max( EPS, Math.min( Math.PI - EPS, phi ) ); 238 | 239 | var radius = offset.length() * scale; 240 | 241 | // restrict radius to be between desired limits 242 | radius = Math.max( this.minDistance, Math.min( this.maxDistance, radius ) ); 243 | 244 | // move target to panned location 245 | this.target.add( pan ); 246 | 247 | offset.x = radius * Math.sin( phi ) * Math.sin( theta ); 248 | offset.y = radius * Math.cos( phi ); 249 | offset.z = radius * Math.sin( phi ) * Math.cos( theta ); 250 | 251 | position.copy( this.target ).add( offset ); 252 | 253 | this.object.lookAt( this.target ); 254 | 255 | thetaDelta = 0; 256 | phiDelta = 0; 257 | scale = 1; 258 | pan.set(0,0,0); 259 | 260 | if ( lastPosition.distanceTo( this.object.position ) > 0 ) { 261 | 262 | this.dispatchEvent( changeEvent ); 263 | 264 | lastPosition.copy( this.object.position ); 265 | 266 | } 267 | 268 | }; 269 | 270 | 271 | function getAutoRotationAngle() { 272 | 273 | return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; 274 | 275 | } 276 | 277 | function getZoomScale() { 278 | 279 | return Math.pow( 0.95, scope.zoomSpeed ); 280 | 281 | } 282 | 283 | function onMouseDown( event ) { 284 | 285 | if ( scope.enabled === false ) { return; } 286 | event.preventDefault(); 287 | 288 | if ( event.button === 0 ) { 289 | if ( scope.noRotate === true ) { return; } 290 | 291 | state = STATE.ROTATE; 292 | 293 | rotateStart.set( event.clientX, event.clientY ); 294 | 295 | } else if ( event.button === 1 ) { 296 | if ( scope.noZoom === true ) { return; } 297 | 298 | state = STATE.DOLLY; 299 | 300 | dollyStart.set( event.clientX, event.clientY ); 301 | 302 | } else if ( event.button === 2 ) { 303 | if ( scope.noPan === true ) { return; } 304 | 305 | state = STATE.PAN; 306 | 307 | panStart.set( event.clientX, event.clientY ); 308 | 309 | } 310 | 311 | // Greggman fix: https://github.com/greggman/three.js/commit/fde9f9917d6d8381f06bf22cdff766029d1761be 312 | scope.domElement.addEventListener( 'mousemove', onMouseMove, false ); 313 | scope.domElement.addEventListener( 'mouseup', onMouseUp, false ); 314 | 315 | } 316 | 317 | function onMouseMove( event ) { 318 | 319 | if ( scope.enabled === false ) return; 320 | 321 | event.preventDefault(); 322 | 323 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement; 324 | 325 | if ( state === STATE.ROTATE ) { 326 | 327 | if ( scope.noRotate === true ) return; 328 | 329 | rotateEnd.set( event.clientX, event.clientY ); 330 | rotateDelta.subVectors( rotateEnd, rotateStart ); 331 | 332 | // rotating across whole screen goes 360 degrees around 333 | scope.rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientWidth * scope.rotateSpeed ); 334 | // rotating up and down along whole screen attempts to go 360, but limited to 180 335 | scope.rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight * scope.rotateSpeed ); 336 | 337 | rotateStart.copy( rotateEnd ); 338 | 339 | } else if ( state === STATE.DOLLY ) { 340 | 341 | if ( scope.noZoom === true ) return; 342 | 343 | dollyEnd.set( event.clientX, event.clientY ); 344 | dollyDelta.subVectors( dollyEnd, dollyStart ); 345 | 346 | if ( dollyDelta.y > 0 ) { 347 | 348 | scope.dollyIn(); 349 | 350 | } else { 351 | 352 | scope.dollyOut(); 353 | 354 | } 355 | 356 | dollyStart.copy( dollyEnd ); 357 | 358 | } else if ( state === STATE.PAN ) { 359 | 360 | if ( scope.noPan === true ) return; 361 | 362 | panEnd.set( event.clientX, event.clientY ); 363 | panDelta.subVectors( panEnd, panStart ); 364 | 365 | scope.pan( panDelta ); 366 | 367 | panStart.copy( panEnd ); 368 | 369 | } 370 | 371 | // Greggman fix: https://github.com/greggman/three.js/commit/fde9f9917d6d8381f06bf22cdff766029d1761be 372 | scope.update(); 373 | 374 | } 375 | 376 | function onMouseUp( /* event */ ) { 377 | 378 | if ( scope.enabled === false ) return; 379 | 380 | // Greggman fix: https://github.com/greggman/three.js/commit/fde9f9917d6d8381f06bf22cdff766029d1761be 381 | scope.domElement.removeEventListener( 'mousemove', onMouseMove, false ); 382 | scope.domElement.removeEventListener( 'mouseup', onMouseUp, false ); 383 | 384 | state = STATE.NONE; 385 | 386 | } 387 | 388 | function onMouseWheel( event ) { 389 | 390 | if ( scope.enabled === false || scope.noZoom === true ) return; 391 | 392 | var delta = 0; 393 | 394 | if ( event.wheelDelta ) { // WebKit / Opera / Explorer 9 395 | 396 | delta = event.wheelDelta; 397 | 398 | } else if ( event.detail ) { // Firefox 399 | 400 | delta = - event.detail; 401 | 402 | } 403 | 404 | if ( delta > 0 ) { 405 | 406 | scope.dollyOut(); 407 | 408 | } else { 409 | 410 | scope.dollyIn(); 411 | 412 | } 413 | 414 | } 415 | 416 | function onKeyDown( event ) { 417 | 418 | if ( scope.enabled === false ) { return; } 419 | if ( scope.noKeys === true ) { return; } 420 | if ( scope.noPan === true ) { return; } 421 | 422 | // pan a pixel - I guess for precise positioning? 423 | // Greggman fix: https://github.com/greggman/three.js/commit/fde9f9917d6d8381f06bf22cdff766029d1761be 424 | var needUpdate = false; 425 | 426 | switch ( event.keyCode ) { 427 | 428 | case scope.keys.UP: 429 | scope.pan( new THREE.Vector2( 0, scope.keyPanSpeed ) ); 430 | needUpdate = true; 431 | break; 432 | case scope.keys.BOTTOM: 433 | scope.pan( new THREE.Vector2( 0, -scope.keyPanSpeed ) ); 434 | needUpdate = true; 435 | break; 436 | case scope.keys.LEFT: 437 | scope.pan( new THREE.Vector2( scope.keyPanSpeed, 0 ) ); 438 | needUpdate = true; 439 | break; 440 | case scope.keys.RIGHT: 441 | scope.pan( new THREE.Vector2( -scope.keyPanSpeed, 0 ) ); 442 | needUpdate = true; 443 | break; 444 | } 445 | 446 | // Greggman fix: https://github.com/greggman/three.js/commit/fde9f9917d6d8381f06bf22cdff766029d1761be 447 | if ( needUpdate ) { 448 | 449 | scope.update(); 450 | 451 | } 452 | 453 | } 454 | 455 | function touchstart( event ) { 456 | 457 | if ( scope.enabled === false ) { return; } 458 | 459 | switch ( event.touches.length ) { 460 | 461 | case 1: // one-fingered touch: rotate 462 | if ( scope.noRotate === true ) { return; } 463 | 464 | state = STATE.TOUCH_ROTATE; 465 | 466 | rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); 467 | break; 468 | 469 | case 2: // two-fingered touch: dolly 470 | if ( scope.noZoom === true ) { return; } 471 | 472 | state = STATE.TOUCH_DOLLY; 473 | 474 | var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; 475 | var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; 476 | var distance = Math.sqrt( dx * dx + dy * dy ); 477 | dollyStart.set( 0, distance ); 478 | break; 479 | 480 | case 3: // three-fingered touch: pan 481 | if ( scope.noPan === true ) { return; } 482 | 483 | state = STATE.TOUCH_PAN; 484 | 485 | panStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); 486 | break; 487 | 488 | default: 489 | state = STATE.NONE; 490 | 491 | } 492 | } 493 | 494 | function touchmove( event ) { 495 | 496 | if ( scope.enabled === false ) { return; } 497 | 498 | event.preventDefault(); 499 | event.stopPropagation(); 500 | 501 | var element = scope.domElement === document ? scope.domElement.body : scope.domElement; 502 | 503 | switch ( event.touches.length ) { 504 | 505 | case 1: // one-fingered touch: rotate 506 | if ( scope.noRotate === true ) { return; } 507 | if ( state !== STATE.TOUCH_ROTATE ) { return; } 508 | 509 | rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); 510 | rotateDelta.subVectors( rotateEnd, rotateStart ); 511 | 512 | // rotating across whole screen goes 360 degrees around 513 | scope.rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientWidth * scope.rotateSpeed ); 514 | // rotating up and down along whole screen attempts to go 360, but limited to 180 515 | scope.rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight * scope.rotateSpeed ); 516 | 517 | rotateStart.copy( rotateEnd ); 518 | break; 519 | 520 | case 2: // two-fingered touch: dolly 521 | if ( scope.noZoom === true ) { return; } 522 | if ( state !== STATE.TOUCH_DOLLY ) { return; } 523 | 524 | var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; 525 | var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; 526 | var distance = Math.sqrt( dx * dx + dy * dy ); 527 | 528 | dollyEnd.set( 0, distance ); 529 | dollyDelta.subVectors( dollyEnd, dollyStart ); 530 | 531 | if ( dollyDelta.y > 0 ) { 532 | 533 | scope.dollyOut(); 534 | 535 | } else { 536 | 537 | scope.dollyIn(); 538 | 539 | } 540 | 541 | dollyStart.copy( dollyEnd ); 542 | break; 543 | 544 | case 3: // three-fingered touch: pan 545 | if ( scope.noPan === true ) { return; } 546 | if ( state !== STATE.TOUCH_PAN ) { return; } 547 | 548 | panEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); 549 | panDelta.subVectors( panEnd, panStart ); 550 | 551 | scope.pan( panDelta ); 552 | 553 | panStart.copy( panEnd ); 554 | break; 555 | 556 | default: 557 | state = STATE.NONE; 558 | 559 | } 560 | 561 | } 562 | 563 | function touchend( /* event */ ) { 564 | 565 | if ( scope.enabled === false ) { return; } 566 | 567 | state = STATE.NONE; 568 | } 569 | 570 | this.domElement.addEventListener( 'contextmenu', function ( event ) { event.preventDefault(); }, false ); 571 | this.domElement.addEventListener( 'mousedown', onMouseDown, false ); 572 | this.domElement.addEventListener( 'mousewheel', onMouseWheel, false ); 573 | this.domElement.addEventListener( 'DOMMouseScroll', onMouseWheel, false ); // firefox 574 | 575 | this.domElement.addEventListener( 'keydown', onKeyDown, false ); 576 | 577 | this.domElement.addEventListener( 'touchstart', touchstart, false ); 578 | this.domElement.addEventListener( 'touchend', touchend, false ); 579 | this.domElement.addEventListener( 'touchmove', touchmove, false ); 580 | 581 | }; 582 | 583 | THREE.OrbitControls.prototype = Object.create( THREE.EventDispatcher.prototype ); -------------------------------------------------------------------------------- /lib/threex.domevent.js: -------------------------------------------------------------------------------- 1 | // This THREEx helper makes it easy to handle the mouse events in your 3D scene 2 | // 3 | // * CHANGES NEEDED 4 | // * handle drag/drop 5 | // * notify events not object3D - like DOM 6 | // * so single object with property 7 | // * DONE bubling implement bubling/capturing 8 | // * DONE implement event.stopPropagation() 9 | // * DONE implement event.type = "click" and co 10 | // * DONE implement event.target 11 | // 12 | // # Lets get started 13 | // 14 | // First you include it in your page 15 | // 16 | // `````` 17 | // 18 | // # use the object oriented api 19 | // 20 | // You bind an event like this 21 | // 22 | // ```mesh.on('click', function(object3d){ ... })``` 23 | // 24 | // To unbind an event, just do 25 | // 26 | // ```mesh.off('click', function(object3d){ ... })``` 27 | // 28 | // As an alternative, there is another naming closer DOM events. 29 | // Pick the one you like, they are doing the same thing 30 | // 31 | // ```mesh.addEventListener('click', function(object3d){ ... })``` 32 | // ```mesh.removeEventListener('click', function(object3d){ ... })``` 33 | // 34 | // # Supported Events 35 | // 36 | // Always in a effort to stay close to usual pratices, the events name are the same as in DOM. 37 | // The semantic is the same too. 38 | // Currently, the available events are 39 | // [click, dblclick, mouseup, mousedown](http://www.quirksmode.org/dom/events/click.html), 40 | // [mouseover and mouse out](http://www.quirksmode.org/dom/events/mouseover.html). 41 | // 42 | // # use the standalone api 43 | // 44 | // The object-oriented api modifies THREE.Object3D class. 45 | // It is a global class, so it may be legitimatly considered unclean by some people. 46 | // If this bother you, simply do ```THREEx.DomEvent.noConflict()``` and use the 47 | // standalone API. In fact, the object oriented API is just a thin wrapper 48 | // on top of the standalone API. 49 | // 50 | // First, you instanciate the object 51 | // 52 | // ```var domEvent = new THREEx.DomEvent();``` 53 | // 54 | // Then you bind an event like this 55 | // 56 | // ```domEvent.bind(mesh, 'click', function(object3d){ object3d.scale.x *= 2; });``` 57 | // 58 | // To unbind an event, just do 59 | // 60 | // ```domEvent.unbind(mesh, 'click', callback);``` 61 | // 62 | // 63 | // # Code 64 | 65 | // 66 | 67 | /** @namespace */ 68 | var THREEx = THREEx || {}; 69 | 70 | // # Constructor 71 | THREEx.DomEvent = function(camera, domElement) 72 | { 73 | this._camera = camera || null; 74 | this._domElement= domElement || document; 75 | this._projector = new THREE.Projector(); 76 | this._selected = null; 77 | this._boundObjs = []; 78 | 79 | // Bind dom event for mouse and touch 80 | var _this = this; 81 | this._$onClick = function(){ _this._onClick.apply(_this, arguments); }; 82 | this._$onDblClick = function(){ _this._onDblClick.apply(_this, arguments); }; 83 | this._$onMouseMove = function(){ _this._onMouseMove.apply(_this, arguments); }; 84 | this._$onMouseDown = function(){ _this._onMouseDown.apply(_this, arguments); }; 85 | this._$onMouseUp = function(){ _this._onMouseUp.apply(_this, arguments); }; 86 | this._$onTouchMove = function(){ _this._onTouchMove.apply(_this, arguments); }; 87 | this._$onTouchStart = function(){ _this._onTouchStart.apply(_this, arguments); }; 88 | this._$onTouchEnd = function(){ _this._onTouchEnd.apply(_this, arguments); }; 89 | this._domElement.addEventListener( 'click' , this._$onClick , false ); 90 | this._domElement.addEventListener( 'dblclick' , this._$onDblClick , false ); 91 | this._domElement.addEventListener( 'mousemove' , this._$onMouseMove , false ); 92 | this._domElement.addEventListener( 'mousedown' , this._$onMouseDown , false ); 93 | this._domElement.addEventListener( 'mouseup' , this._$onMouseUp , false ); 94 | this._domElement.addEventListener( 'touchmove' , this._$onTouchMove , false ); 95 | this._domElement.addEventListener( 'touchstart' , this._$onTouchStart , false ); 96 | this._domElement.addEventListener( 'touchend' , this._$onTouchEnd , false ); 97 | } 98 | 99 | // # Destructor 100 | THREEx.DomEvent.prototype.destroy = function() 101 | { 102 | // unBind dom event for mouse and touch 103 | this._domElement.removeEventListener( 'click' , this._$onClick , false ); 104 | this._domElement.removeEventListener( 'dblclick' , this._$onDblClick , false ); 105 | this._domElement.removeEventListener( 'mousemove' , this._$onMouseMove , false ); 106 | this._domElement.removeEventListener( 'mousedown' , this._$onMouseDown , false ); 107 | this._domElement.removeEventListener( 'mouseup' , this._$onMouseUp , false ); 108 | this._domElement.removeEventListener( 'touchmove' , this._$onTouchMove , false ); 109 | this._domElement.removeEventListener( 'touchstart' , this._$onTouchStart , false ); 110 | this._domElement.removeEventListener( 'touchend' , this._$onTouchEnd , false ); 111 | } 112 | 113 | THREEx.DomEvent.eventNames = [ 114 | "click", 115 | "dblclick", 116 | "mouseover", 117 | "mouseout", 118 | "mousedown", 119 | "mouseup" 120 | ]; 121 | 122 | /********************************************************************************/ 123 | /* domevent context */ 124 | /********************************************************************************/ 125 | 126 | // handle domevent context in object3d instance 127 | 128 | THREEx.DomEvent.prototype._objectCtxInit = function(object3d){ 129 | object3d._3xDomEvent = {}; 130 | } 131 | THREEx.DomEvent.prototype._objectCtxDeinit = function(object3d){ 132 | delete object3d._3xDomEvent; 133 | } 134 | THREEx.DomEvent.prototype._objectCtxIsInit = function(object3d){ 135 | return object3d._3xDomEvent ? true : false; 136 | } 137 | THREEx.DomEvent.prototype._objectCtxGet = function(object3d){ 138 | return object3d._3xDomEvent; 139 | } 140 | 141 | /********************************************************************************/ 142 | /* */ 143 | /********************************************************************************/ 144 | 145 | /** 146 | * Getter/Setter for camera 147 | */ 148 | THREEx.DomEvent.prototype.camera = function(value) 149 | { 150 | if( value ) this._camera = value; 151 | return this._camera; 152 | } 153 | 154 | THREEx.DomEvent.prototype.bind = function(object3d, eventName, callback, useCapture) 155 | { 156 | console.assert( THREEx.DomEvent.eventNames.indexOf(eventName) !== -1, "not available events:"+eventName ); 157 | 158 | if( !this._objectCtxIsInit(object3d) ) this._objectCtxInit(object3d); 159 | var objectCtx = this._objectCtxGet(object3d); 160 | if( !objectCtx[eventName+'Handlers'] ) objectCtx[eventName+'Handlers'] = []; 161 | 162 | objectCtx[eventName+'Handlers'].push({ 163 | callback : callback, 164 | useCapture : useCapture 165 | }); 166 | 167 | // add this object in this._boundObjs 168 | this._boundObjs.push(object3d); 169 | } 170 | 171 | THREEx.DomEvent.prototype.unbind = function(object3d, eventName, callback) 172 | { 173 | console.assert( THREEx.DomEvent.eventNames.indexOf(eventName) !== -1, "not available events:"+eventName ); 174 | 175 | if( !this._objectCtxIsInit(object3d) ) this._objectCtxInit(object3d); 176 | 177 | var objectCtx = this._objectCtxGet(object3d); 178 | if( !objectCtx[eventName+'Handlers'] ) objectCtx[eventName+'Handlers'] = []; 179 | 180 | var handlers = objectCtx[eventName+'Handlers']; 181 | for(var i = 0; i < handlers.length; i++){ 182 | var handler = handlers[i]; 183 | if( callback != handler.callback ) continue; 184 | if( useCapture != handler.useCapture ) continue; 185 | handlers.splice(i, 1) 186 | break; 187 | } 188 | // from this object from this._boundObjs 189 | var index = this._boundObjs.indexOf(object3d); 190 | console.assert( index !== -1 ); 191 | this._boundObjs.splice(index, 1); 192 | } 193 | 194 | THREEx.DomEvent.prototype._bound = function(eventName, object3d) 195 | { 196 | var objectCtx = this._objectCtxGet(object3d); 197 | if( !objectCtx ) return false; 198 | return objectCtx[eventName+'Handlers'] ? true : false; 199 | } 200 | 201 | /********************************************************************************/ 202 | /* onMove */ 203 | /********************************************************************************/ 204 | 205 | // # handle mousemove kind of events 206 | 207 | THREEx.DomEvent.prototype._onMove = function(mouseX, mouseY, origDomEvent) 208 | { 209 | var vector = new THREE.Vector3( mouseX, mouseY, 1 ); 210 | this._projector.unprojectVector( vector, this._camera ); 211 | 212 | var ray = new THREE.Raycaster( this._camera.position, vector.sub( this._camera.position ).normalize() ); 213 | var intersects = ray.intersectObjects( this._boundObjs ); 214 | 215 | var oldSelected = this._selected; 216 | 217 | if( intersects.length > 0 ){ 218 | var intersect = intersects[ 0 ]; 219 | var newSelected = intersect.object; 220 | this._selected = newSelected; 221 | 222 | var notifyOver, notifyOut; 223 | if( oldSelected != newSelected ){ 224 | // if newSelected bound mouseenter, notify it 225 | notifyOver = this._bound('mouseover', newSelected); 226 | // if there is a oldSelect and oldSelected bound mouseleave, notify it 227 | notifyOut = oldSelected && this._bound('mouseout', oldSelected); 228 | } 229 | }else{ 230 | // if there is a oldSelect and oldSelected bound mouseleave, notify it 231 | notifyOut = oldSelected && this._bound('mouseout', oldSelected); 232 | this._selected = null; 233 | } 234 | 235 | // notify mouseEnter - done at the end with a copy of the list to allow callback to remove handlers 236 | notifyOver && this._notify('mouseover', newSelected, origDomEvent); 237 | // notify mouseLeave - done at the end with a copy of the list to allow callback to remove handlers 238 | notifyOut && this._notify('mouseout', oldSelected, origDomEvent); 239 | } 240 | 241 | 242 | /********************************************************************************/ 243 | /* onEvent */ 244 | /********************************************************************************/ 245 | 246 | // # handle click kind of events 247 | 248 | THREEx.DomEvent.prototype._onEvent = function(eventName, mouseX, mouseY, origDomEvent) 249 | { 250 | var vector = new THREE.Vector3( mouseX, mouseY, 1 ); 251 | this._projector.unprojectVector( vector, this._camera ); 252 | 253 | vector.sub( this._camera.position ).normalize() 254 | var ray = new THREE.Raycaster( this._camera.position, vector ); 255 | var intersects = ray.intersectObjects( this._boundObjs ); 256 | 257 | // if there are no intersections, return now 258 | if( intersects.length === 0 ) return; 259 | 260 | // init some vairables 261 | var intersect = intersects[0]; 262 | var object3d = intersect.object; 263 | var face = intersect.face; //joews 264 | var objectCtx = this._objectCtxGet(object3d); 265 | if( !objectCtx ) return; 266 | 267 | // notify handlers 268 | this._notify(eventName, object3d, origDomEvent, face); 269 | } 270 | 271 | THREEx.DomEvent.prototype._notify = function(eventName, object3d, origDomEvent, targetFace) 272 | { 273 | var objectCtx = this._objectCtxGet(object3d); 274 | var handlers = objectCtx ? objectCtx[eventName+'Handlers'] : null; 275 | 276 | // do bubbling 277 | if( !objectCtx || !handlers || handlers.length === 0 ){ 278 | object3d.parent && this._notify(eventName, object3d.parent); 279 | return; 280 | } 281 | 282 | // notify all handlers 283 | var handlers = objectCtx[eventName+'Handlers']; 284 | for(var i = 0; i < handlers.length; i++){ 285 | var handler = handlers[i]; 286 | var toPropagate = true; 287 | handler.callback({ 288 | type : eventName, 289 | target : object3d, 290 | targetFace: targetFace, //joews 291 | origDomEvent : origDomEvent, 292 | stopPropagation : function(){ 293 | toPropagate = false; 294 | } 295 | }); 296 | if( !toPropagate ) continue; 297 | // do bubbling 298 | if( handler.useCapture === false ){ 299 | object3d.parent && this._notify(eventName, object3d.parent); 300 | } 301 | } 302 | } 303 | 304 | /********************************************************************************/ 305 | /* handle mouse events */ 306 | /********************************************************************************/ 307 | // # handle mouse events 308 | 309 | THREEx.DomEvent.prototype._onMouseDown = function(event){ return this._onMouseEvent('mousedown', event); } 310 | THREEx.DomEvent.prototype._onMouseUp = function(event){ return this._onMouseEvent('mouseup' , event); } 311 | 312 | 313 | THREEx.DomEvent.prototype._onMouseEvent = function(eventName, domEvent) 314 | { 315 | var mouseX = +(domEvent.clientX / window.innerWidth ) * 2 - 1; 316 | var mouseY = -(domEvent.clientY / window.innerHeight) * 2 + 1; 317 | return this._onEvent(eventName, mouseX, mouseY, domEvent); 318 | } 319 | 320 | THREEx.DomEvent.prototype._onMouseMove = function(domEvent) 321 | { 322 | var mouseX = +(domEvent.clientX / window.innerWidth ) * 2 - 1; 323 | var mouseY = -(domEvent.clientY / window.innerHeight) * 2 + 1; 324 | return this._onMove(mouseX, mouseY, domEvent); 325 | } 326 | 327 | THREEx.DomEvent.prototype._onClick = function(event) 328 | { 329 | // TODO handle touch ? 330 | return this._onMouseEvent('click' , event); 331 | } 332 | THREEx.DomEvent.prototype._onDblClick = function(event) 333 | { 334 | // TODO handle touch ? 335 | return this._onMouseEvent('dblclick' , event); 336 | } 337 | 338 | /********************************************************************************/ 339 | /* handle touch events */ 340 | /********************************************************************************/ 341 | // # handle touch events 342 | 343 | 344 | THREEx.DomEvent.prototype._onTouchStart = function(event){ return this._onTouchEvent('mousedown', event); } 345 | THREEx.DomEvent.prototype._onTouchEnd = function(event){ return this._onTouchEvent('mouseup' , event); } 346 | 347 | THREEx.DomEvent.prototype._onTouchMove = function(domEvent) 348 | { 349 | if( domEvent.touches.length != 1 ) return undefined; 350 | 351 | domEvent.preventDefault(); 352 | 353 | var mouseX = +(domEvent.touches[ 0 ].pageX / window.innerWidth ) * 2 - 1; 354 | var mouseY = -(domEvent.touches[ 0 ].pageY / window.innerHeight) * 2 + 1; 355 | return this._onMove('mousemove', mouseX, mouseY, domEvent); 356 | } 357 | 358 | THREEx.DomEvent.prototype._onTouchEvent = function(eventName, domEvent) 359 | { 360 | if( domEvent.touches.length != 1 ) return undefined; 361 | 362 | domEvent.preventDefault(); 363 | 364 | var mouseX = +(domEvent.touches[ 0 ].pageX / window.innerWidth ) * 2 - 1; 365 | var mouseY = -(domEvent.touches[ 0 ].pageY / window.innerHeight) * 2 + 1; 366 | return this._onEvent(eventName, mouseX, mouseY, domEvent); 367 | } 368 | -------------------------------------------------------------------------------- /lib/threex.domevent.object3d.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************/ 2 | // # Patch THREE.Object3D 3 | /********************************************************************************/ 4 | 5 | // handle noConflit. 6 | THREEx.DomEvent.noConflict = function(){ 7 | THREEx.DomEvent.noConflict.symbols.forEach(function(symbol){ 8 | THREE.Object3D.prototype[symbol] = THREEx.DomEvent.noConflict.previous[symbol] 9 | }) 10 | } 11 | // Backup previous values to restore them later if needed. 12 | THREEx.DomEvent.noConflict.symbols = ['on', 'off', 'addEventListener', 'removeEventListener']; 13 | THREEx.DomEvent.noConflict.previous = {}; 14 | THREEx.DomEvent.noConflict.symbols.forEach(function(symbol){ 15 | THREEx.DomEvent.noConflict.previous[symbol] = THREE.Object3D.prototype[symbol] 16 | }) 17 | 18 | // begin the actual patching of THREE.Object3D 19 | 20 | // create the global instance of THREEx.DomEvent 21 | THREE.Object3D._threexDomEvent = new THREEx.DomEvent(); 22 | 23 | // # wrap mouseevents.bind() 24 | THREE.Object3D.prototype.on = 25 | THREE.Object3D.prototype.addEventListener = function(eventName, callback){ 26 | THREE.Object3D._threexDomEvent.bind(this, eventName, callback); 27 | return this; 28 | } 29 | 30 | // # wrap mouseevents.unbind() 31 | THREE.Object3D.prototype.off = 32 | THREE.Object3D.prototype.removeEventListener = function(eventName, callback){ 33 | THREE.Object3D._threexDomEvent.unbind(this, eventName, callback); 34 | return this; 35 | } -------------------------------------------------------------------------------- /rubik.js: -------------------------------------------------------------------------------- 1 | // element: a jQuery object containing the DOM element to use 2 | // dimensions: the number of cubes per row/column (default 3) 3 | // background: the scene background colour 4 | function Rubik(element, dimensions, background) { 5 | 6 | dimensions = dimensions || 3; 7 | background = background || 0x303030; 8 | 9 | var width = element.innerWidth(), 10 | height = element.innerHeight(); 11 | 12 | var debug = false; 13 | 14 | /*** three.js boilerplate ***/ 15 | var scene = new THREE.Scene(), 16 | camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000), 17 | renderer = new THREE.WebGLRenderer({ antialias: true }); 18 | 19 | renderer.setClearColor(background, 1.0); 20 | renderer.setSize(width, height); 21 | renderer.shadowMapEnabled = true; 22 | element.append(renderer.domElement); 23 | 24 | camera.position = new THREE.Vector3(-20, 20, 30); 25 | camera.lookAt(scene.position); 26 | THREE.Object3D._threexDomEvent.camera(camera); 27 | 28 | /*** Lights ***/ 29 | scene.add(new THREE.AmbientLight(0xffffff)); 30 | //TODO: add a spotlight that takes the orbitcontrols into account to stay "static" 31 | 32 | /*** Camera controls ***/ 33 | var orbitControl = new THREE.OrbitControls(camera, renderer.domElement); 34 | 35 | function enableCameraControl() { 36 | orbitControl.noRotate = false; 37 | } 38 | 39 | function disableCameraControl() { 40 | orbitControl.noRotate = true; 41 | } 42 | 43 | /*** Debug aids ***/ 44 | if(debug) { 45 | scene.add(new THREE.AxisHelper( 20 )); 46 | } 47 | 48 | /*** Click handling ***/ 49 | 50 | //Do the given coordinates intersect with any cubes? 51 | var SCREEN_HEIGHT = window.innerHeight; 52 | var SCREEN_WIDTH = window.innerWidth; 53 | 54 | var raycaster = new THREE.Raycaster(), 55 | projector = new THREE.Projector(); 56 | 57 | function isMouseOverCube(mouseX, mouseY) { 58 | var directionVector = new THREE.Vector3(); 59 | 60 | //Normalise mouse x and y 61 | var x = ( mouseX / SCREEN_WIDTH ) * 2 - 1; 62 | var y = -( mouseY / SCREEN_HEIGHT ) * 2 + 1; 63 | 64 | directionVector.set(x, y, 1); 65 | 66 | projector.unprojectVector(directionVector, camera); 67 | directionVector.sub(camera.position); 68 | directionVector.normalize(); 69 | raycaster.set(camera.position, directionVector); 70 | 71 | return raycaster.intersectObjects(allCubes, true).length > 0; 72 | } 73 | 74 | //Return the axis which has the greatest maginitude for the vector v 75 | function principalComponent(v) { 76 | var maxAxis = 'x', 77 | max = Math.abs(v.x); 78 | if(Math.abs(v.y) > max) { 79 | maxAxis = 'y'; 80 | max = Math.abs(v.y); 81 | } 82 | if(Math.abs(v.z) > max) { 83 | maxAxis = 'z'; 84 | max = Math.abs(v.z); 85 | } 86 | return maxAxis; 87 | } 88 | 89 | //For each mouse down, track the position of the cube that 90 | // we clicked (clickVector) and the face object that we clicked on 91 | // (clickFace) 92 | var clickVector, clickFace; 93 | 94 | //Keep track of the last cube that the user's drag exited, so we can make 95 | // valid movements that end outside of the Rubik's cube 96 | var lastCube; 97 | 98 | var onCubeMouseDown = function(e, cube) { 99 | disableCameraControl(); 100 | 101 | //Maybe add move check in here 102 | if(true || !isMoving) { 103 | clickVector = cube.rubikPosition.clone(); 104 | 105 | var centroid = e.targetFace.centroid.clone(); 106 | centroid.applyMatrix4(cube.matrixWorld); 107 | 108 | //Which face (of the overall cube) did we click on? 109 | if(nearlyEqual(Math.abs(centroid.x), maxExtent)) 110 | clickFace = 'x'; 111 | else if(nearlyEqual(Math.abs(centroid.y), maxExtent)) 112 | clickFace = 'y'; 113 | else if(nearlyEqual(Math.abs(centroid.z), maxExtent)) 114 | clickFace = 'z'; 115 | } 116 | }; 117 | 118 | //Matrix of the axis that we should rotate for 119 | // each face-drag action 120 | // F a c e 121 | // D X Y Z 122 | // r X - Z Y 123 | // a Y Z - X 124 | // g Z Y X - 125 | var transitions = { 126 | 'x': {'y': 'z', 'z': 'y'}, 127 | 'y': {'x': 'z', 'z': 'x'}, 128 | 'z': {'x': 'y', 'y': 'x'} 129 | } 130 | 131 | var onCubeMouseUp = function(e, cube) { 132 | 133 | if(clickVector) { 134 | //TODO: use the actual mouse end coordinates for finer drag control 135 | var dragVector = cube.rubikPosition.clone(); 136 | dragVector.sub(clickVector); 137 | 138 | //Don't move if the "drag" was too small, to allow for 139 | // click-and-change-mind. 140 | if(dragVector.length() > cubeSize) { 141 | 142 | //Rotate with the most significant component of the drag vector 143 | // (excluding the current axis, because we can't rotate that way) 144 | var dragVectorOtherAxes = dragVector.clone(); 145 | dragVectorOtherAxes[clickFace] = 0; 146 | 147 | var maxAxis = principalComponent(dragVectorOtherAxes); 148 | 149 | var rotateAxis = transitions[clickFace][maxAxis], 150 | direction = dragVector[maxAxis] >= 0 ? 1 : -1; 151 | 152 | //Reverse direction of some rotations for intuitive control 153 | //TODO: find a general solution! 154 | if(clickFace == 'z' && rotateAxis == 'x' || 155 | clickFace == 'x' && rotateAxis == 'z' || 156 | clickFace == 'y' && rotateAxis == 'z') 157 | direction *= -1; 158 | 159 | if(clickFace == 'x' && clickVector.x > 0 || 160 | clickFace == 'y' && clickVector.y < 0 || 161 | clickFace == 'z' && clickVector.z < 0) 162 | direction *= -1; 163 | 164 | pushMove(cube, clickVector.clone(), rotateAxis, direction); 165 | startNextMove(); 166 | enableCameraControl(); 167 | } else { 168 | console.log("Drag me some more please!"); 169 | } 170 | } 171 | }; 172 | 173 | //If the mouse was released outside of the Rubik's cube, use the cube that the mouse 174 | // was last over to determine which move to make 175 | var onCubeMouseOut = function(e, cube) { 176 | //TODO: there is a possibility that, at some rotations, we may catch unintentional 177 | // cubes on the way out. We should check that the selected cube is on the current 178 | // drag vector. 179 | lastCube = cube; 180 | } 181 | 182 | element.on('mouseup', function(e) { 183 | if(!isMouseOverCube(e.clientX, e.clientY)) { 184 | if(lastCube) 185 | onCubeMouseUp(e, lastCube); 186 | } 187 | }); 188 | 189 | /*** Build 27 cubes ***/ 190 | //TODO: colour the insides of all of the faces black 191 | // (probably colour all faces black to begin with, then "whitelist" exterior faces) 192 | var colours = [0xC41E3A, 0x009E60, 0x0051BA, 0xFF5800, 0xFFD500, 0xFFFFFF], 193 | faceMaterials = colours.map(function(c) { 194 | return new THREE.MeshLambertMaterial({ color: c , ambient: c }); 195 | }), 196 | cubeMaterials = new THREE.MeshFaceMaterial(faceMaterials); 197 | 198 | var cubeSize = 3, 199 | spacing = 0.5; 200 | 201 | var increment = cubeSize + spacing, 202 | maxExtent = (cubeSize * dimensions + spacing * (dimensions - 1)) / 2, 203 | allCubes = []; 204 | 205 | function newCube(x, y, z) { 206 | var cubeGeometry = new THREE.CubeGeometry(cubeSize, cubeSize, cubeSize); 207 | var cube = new THREE.Mesh(cubeGeometry, cubeMaterials); 208 | cube.castShadow = true; 209 | 210 | cube.position = new THREE.Vector3(x, y, z); 211 | cube.rubikPosition = cube.position.clone(); 212 | 213 | cube.on('mousedown', function(e) { 214 | onCubeMouseDown(e, cube); 215 | }); 216 | 217 | cube.on('mouseup', function(e) { 218 | onCubeMouseUp(e, cube); 219 | }); 220 | 221 | cube.on('mouseout', function(e) { 222 | onCubeMouseOut(e, cube); 223 | }); 224 | 225 | scene.add(cube); 226 | allCubes.push(cube); 227 | } 228 | 229 | var positionOffset = (dimensions - 1) / 2; 230 | for(var i = 0; i < dimensions; i ++) { 231 | for(var j = 0; j < dimensions; j ++) { 232 | for(var k = 0; k < dimensions; k ++) { 233 | 234 | var x = (i - positionOffset) * increment, 235 | y = (j - positionOffset) * increment, 236 | z = (k - positionOffset) * increment; 237 | 238 | newCube(x, y, z); 239 | } 240 | } 241 | } 242 | 243 | /*** Manage transition states ***/ 244 | 245 | //TODO: encapsulate each transition into a "Move" object, and keep a stack of moves 246 | // - that will allow us to easily generalise to other states like a "hello" state which 247 | // could animate the cube, or a "complete" state which could do an animation to celebrate 248 | // solving. 249 | var moveEvents = $({}); 250 | 251 | //Maintain a queue of moves so we can perform compound actions like shuffle and solve 252 | var moveQueue = [], 253 | completedMoveStack = [], 254 | currentMove; 255 | 256 | //Are we in the middle of a transition? 257 | var isMoving = false, 258 | moveAxis, moveN, moveDirection, 259 | rotationSpeed = 0.2; 260 | 261 | //http://stackoverflow.com/questions/20089098/three-js-adding-and-removing-children-of-rotated-objects 262 | var pivot = new THREE.Object3D(), 263 | activeGroup = []; 264 | 265 | function nearlyEqual(a, b, d) { 266 | d = d || 0.001; 267 | return Math.abs(a - b) <= d; 268 | } 269 | 270 | //Select the plane of cubes that aligns with clickVector 271 | // on the given axis 272 | function setActiveGroup(axis) { 273 | if(clickVector) { 274 | activeGroup = []; 275 | 276 | allCubes.forEach(function(cube) { 277 | if(nearlyEqual(cube.rubikPosition[axis], clickVector[axis])) { 278 | activeGroup.push(cube); 279 | } 280 | }); 281 | } else { 282 | console.log("Nothing to move!"); 283 | } 284 | } 285 | 286 | var pushMove = function(cube, clickVector, axis, direction) { 287 | moveQueue.push({ cube: cube, vector: clickVector, axis: axis, direction: direction }); 288 | } 289 | 290 | var startNextMove = function() { 291 | var nextMove = moveQueue.pop(); 292 | 293 | if(nextMove) { 294 | clickVector = nextMove.vector; 295 | 296 | var direction = nextMove.direction || 1, 297 | axis = nextMove.axis; 298 | 299 | if(clickVector) { 300 | 301 | if(!isMoving) { 302 | isMoving = true; 303 | moveAxis = axis; 304 | moveDirection = direction; 305 | 306 | setActiveGroup(axis); 307 | 308 | pivot.rotation.set(0,0,0); 309 | pivot.updateMatrixWorld(); 310 | scene.add(pivot); 311 | 312 | activeGroup.forEach(function(e) { 313 | THREE.SceneUtils.attach(e, scene, pivot); 314 | }); 315 | 316 | currentMove = nextMove; 317 | } else { 318 | console.log("Already moving!"); 319 | } 320 | } else { 321 | console.log("Nothing to move!"); 322 | } 323 | } else { 324 | moveEvents.trigger('deplete'); 325 | } 326 | } 327 | 328 | function doMove() { 329 | //Move a quarter turn then stop 330 | if(pivot.rotation[moveAxis] >= Math.PI / 2) { 331 | //Compensate for overshoot. TODO: use a tweening library 332 | pivot.rotation[moveAxis] = Math.PI / 2; 333 | moveComplete(); 334 | } else if(pivot.rotation[moveAxis] <= Math.PI / -2) { 335 | pivot.rotation[moveAxis] = Math.PI / -2; 336 | moveComplete() 337 | } else { 338 | pivot.rotation[moveAxis] += (moveDirection * rotationSpeed); 339 | } 340 | } 341 | 342 | var moveComplete = function() { 343 | isMoving = false; 344 | moveAxis, moveN, moveDirection = undefined; 345 | clickVector = undefined; 346 | 347 | pivot.updateMatrixWorld(); 348 | scene.remove(pivot); 349 | activeGroup.forEach(function(cube) { 350 | cube.updateMatrixWorld(); 351 | 352 | cube.rubikPosition = cube.position.clone(); 353 | cube.rubikPosition.applyMatrix4(pivot.matrixWorld); 354 | 355 | THREE.SceneUtils.detach(cube, pivot, scene); 356 | }); 357 | 358 | completedMoveStack.push(currentMove); 359 | 360 | moveEvents.trigger('complete'); 361 | 362 | //Are there any more queued moves? 363 | startNextMove(); 364 | } 365 | 366 | 367 | function render() { 368 | 369 | //States 370 | //TODO: generalise to something like "activeState.tick()" - see comments 371 | // on encapsulation above 372 | if(isMoving) { 373 | doMove(); 374 | } 375 | 376 | renderer.render(scene, camera); 377 | requestAnimationFrame(render); 378 | } 379 | 380 | /*** Util ***/ 381 | function randomInt(min, max) { 382 | return Math.floor(Math.random() * (max - min + 1) + min); 383 | } 384 | 385 | //Go! 386 | render(); 387 | 388 | //Public API 389 | return { 390 | shuffle: function() { 391 | function randomAxis() { 392 | return ['x', 'y', 'z'][randomInt(0,2)]; 393 | } 394 | 395 | function randomDirection() { 396 | var x = randomInt(0,1); 397 | if(x == 0) x = -1; 398 | return x; 399 | } 400 | 401 | function randomCube() { 402 | var i = randomInt(0, allCubes.length - 1); 403 | //TODO: don't return a centre cube 404 | return allCubes[i]; 405 | } 406 | 407 | var nMoves = randomInt(10, 40); 408 | for(var i = 0; i < nMoves; i ++) { 409 | //TODO: don't reselect the same axis? 410 | var cube = randomCube(); 411 | pushMove(cube, cube.position.clone(), randomAxis(), randomDirection()); 412 | } 413 | 414 | startNextMove(); 415 | }, 416 | 417 | //A naive solver - step backwards through all completed steps 418 | solve: function() { 419 | if(!isMoving) { 420 | completedMoveStack.forEach(function(move) { 421 | pushMove(move.cube, move.vector, move.axis, move.direction * -1); 422 | }); 423 | 424 | //Don't remember the moves we're making whilst solving 425 | completedMoveStack = []; 426 | 427 | moveEvents.one('deplete', function() { 428 | completedMoveStack = []; 429 | }); 430 | 431 | startNextMove(); 432 | } 433 | }, 434 | 435 | //Rewind the last move 436 | undo: function() { 437 | if(!isMoving) { 438 | var lastMove = completedMoveStack.pop(); 439 | if(lastMove) { 440 | //clone 441 | var stackToRestore = completedMoveStack.slice(0); 442 | pushMove(lastMove.cube, lastMove.vector, lastMove.axis, lastMove.direction * -1); 443 | 444 | moveEvents.one('complete', function() { 445 | completedMoveStack = stackToRestore; 446 | }); 447 | 448 | startNextMove(); 449 | } 450 | } 451 | } 452 | } 453 | } 454 | 455 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | overflow: hidden; 8 | background-color: #303030; 9 | font-family: 'Alegreya Sans SC'; 10 | color: #eee; 11 | } 12 | 13 | p#info { 14 | position: absolute; 15 | bottom: 0; 16 | margin: 10px; 17 | } 18 | 19 | a { 20 | color: #ff7600; 21 | text-decoration: none; 22 | } 23 | 24 | #controls { 25 | position: absolute; 26 | top: 0; 27 | list-style-type: none; 28 | margin: 5px; 29 | padding: 0; 30 | max-width: 70%; 31 | 32 | } 33 | 34 | #controls > li { 35 | margin: 0 5px; 36 | padding: 0; 37 | float: left; 38 | } 39 | 40 | #controls > li:after { 41 | content: '~'; 42 | color: #999; 43 | } 44 | 45 | #controls > li:last-child:after { 46 | content: none; 47 | } 48 | 49 | #controls > li a, #controls > li label { 50 | margin-right: 10px; 51 | font-size: 1.2em; 52 | color: #eee; 53 | line-height: 2em; 54 | } 55 | 56 | #controls > li select { 57 | color: #333; 58 | font-size: 1em; 59 | } 60 | 61 | #controls > li a:hover { 62 | color: #ff7600; 63 | } 64 | 65 | #scene { 66 | height: 100%; 67 | } 68 | --------------------------------------------------------------------------------